Compare commits

...

914 Commits

Author SHA1 Message Date
brandon 92f2d74637 feat: auto-derive oidc.discovery_url from NEXTCLOUD_HOST
Bump version / Bump version and create changelog for monorepo components (push) Failing after 8s
When OIDC_DISCOVERY_URL is not explicitly set, the status endpoint now
auto-derives the discovery URL from NEXTCLOUD_HOST using the standard
well-known path. This allows Astrolabe to discover OIDC endpoints
without requiring explicit OIDC configuration.

The oidc block is now included in the status response regardless of
auth mode when a discovery URL is available (explicit or derived),
enabling smoother auth mode transitions.

Closes #1
2026-03-29 12:56:50 -06:00
github-actions[bot] 656acc2c1f bump: version 0.58.2 → 0.58.3 2026-03-16 17:38:05 +00:00
Chris Coutinho c726e25e8b Merge pull request #625 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.6.0
2026-03-16 18:37:45 +01:00
renovate-bot-cbcoutinho[bot] 355bd1bad3 chore(deps): update astral-sh/setup-uv action to v7.6.0 2026-03-16 17:22:55 +00:00
github-actions[bot] 989d3f2857 bump: version 0.58.1 → 0.58.2 2026-03-14 15:56:15 +00:00
Chris Coutinho 92d5cd4e26 Merge pull request #613 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.72
2026-03-14 16:55:59 +01:00
renovate-bot-cbcoutinho[bot] 5823286907 chore(deps): update anthropics/claude-code-action action to v1.0.72 2026-03-14 05:23:04 +00:00
github-actions[bot] 7fb6613bc2 bump: version 0.58.0 → 0.58.1 2026-03-03 11:33:21 +00:00
Chris Coutinho cd6f0ffa63 Merge pull request #606 from cbcoutinho/renovate/node-24.x
chore(deps): update dependency node to v24
2026-03-03 12:26:33 +01:00
Chris Coutinho 5d98858bb6 Merge pull request #603 from cbcoutinho/renovate/docker.io-library-nextcloud-33.0.0
chore(deps): update docker.io/library/nextcloud:33.0.0 docker digest to d53f6cb
2026-03-03 12:26:07 +01:00
Chris Coutinho af7c752cc1 Merge pull request #607 from cbcoutinho/renovate/migrate-config
chore(config): migrate Renovate config
2026-03-03 12:25:36 +01:00
Chris Coutinho 2526390ce8 Merge pull request #604 from cbcoutinho/renovate/docker.io-library-nextcloud-31.x
chore(deps): update docker.io/library/nextcloud docker tag to v31.0.14
2026-03-03 12:24:50 +01:00
renovate-bot-cbcoutinho[bot] 0b5571f3d7 chore(config): migrate config renovate.json 2026-03-03 11:18:14 +00:00
renovate-bot-cbcoutinho[bot] 059f37d093 chore(deps): update dependency node to v24 2026-03-03 11:18:05 +00:00
renovate-bot-cbcoutinho[bot] 28ad0aefbf chore(deps): update docker.io/library/nextcloud docker tag to v31.0.14 2026-03-03 11:17:49 +00:00
renovate-bot-cbcoutinho[bot] 6ce9599757 chore(deps): update docker.io/library/nextcloud:33.0.0 docker digest to d53f6cb 2026-03-03 11:17:25 +00:00
github-actions[bot] 1cdf148899 bump: version 0.57.94 → 0.58.0 2026-03-03 08:42:10 +00:00
github-actions[bot] 8b16d79d6c bump: version 0.64.5 → 0.65.0 2026-03-03 08:42:10 +00:00
Chris Coutinho 45cc4c68fc Merge pull request #589 from cbcoutinho/feat/docker-compose-profiles-login-flow
feat: Docker Compose profiles and Login Flow v2 integration tests
2026-03-03 09:41:48 +01:00
github-actions[bot] b4c98b25ee bump: version 0.57.93 → 0.57.94 2026-03-03 08:33:48 +00:00
github-actions[bot] 1176479ec1 bump: version 0.64.4 → 0.64.5 2026-03-03 08:33:47 +00:00
Chris Coutinho 0f8b1c6325 Merge pull request #602 from cbcoutinho/fix/contacts-vcard-dict-format-601
fix: handle pythonvCard4 dict-format fields and missing phones (#601)
2026-03-03 09:33:27 +01:00
Chris Coutinho fdb7b87baf fix: handle pythonvCard4 dict-format fields and missing phone numbers (#601)
Fix three related contacts bugs:
- Parse dict-format vCard fields ({value, type}) that pythonvCard4 returns,
  which previously crashed Pydantic validation expecting plain strings
- Include tel field in client output so phone numbers reach MCP tools
- Clarify addressbook parameter expects URI slug, not displayname

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:32:53 +01:00
Chris Coutinho 47fb562326 fix: replace assert with proper guard and invalidate scope cache after provisioning
Replace `assert entry.code_challenge` with a proper if-guard returning a
500 JSON error in the token endpoint, since Python's -O flag strips
asserts and would silently disable PKCE enforcement.

Invalidate the scope cache immediately after Login Flow v2 provisioning
completes, so users no longer hit ProvisioningRequiredError for up to
5 minutes after successfully authenticating.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 09:31:36 +01:00
Chris Coutinho 1fae6920be fix: disable NC rate limiting in dev/CI and add token endpoint diagnostics
Disable Nextcloud's bruteforce protection and rate limiting via a new
post-installation hook, preventing 429 errors during repeated DCR calls
in CI. Add warning-level logging to all 8 error paths in the AS proxy
token endpoint to make login-flow 400 errors diagnosable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 08:57:02 +01:00
github-actions[bot] 184415eca1 bump: version 0.57.92 → 0.57.93 2026-03-03 06:13:03 +00:00
Chris Coutinho 658fd7e138 Merge pull request #600 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.66
2026-03-03 07:12:48 +01:00
renovate-bot-cbcoutinho[bot] a5d2025797 chore(deps): update anthropics/claude-code-action action to v1.0.66 2026-03-02 17:17:24 +00:00
Chris Coutinho f43343356e fix: address review feedback — security, caching, CI 429 retry
- Add 429 retry with exponential backoff to register_client() (fixes CI
  oauth matrix failures from parallel DCR requests)
- Make client_id, redirect_uri, and PKCE mandatory at token endpoint
- Add null-checks for discovery_url and OAuth credentials in proxy flows
- Add OIDC discovery document caching with 5-min TTL
- Add per-IP rate limiting on /oauth/register DCR proxy
- Discover DCR endpoint from OIDC discovery instead of hardcoding
- Extract extract_user_id_from_token to auth/token_utils.py (breaks
  circular imports between server/ and auth/ layers)
- Add TTL scope cache in scope_authorization.py (avoids DB hit per tool)
- Add defense-in-depth scope validation in storage layer
- Broaden elicitation exception handling with graceful fallback
- Add idempotentHint to nc_auth_check_status, return "pending" status
  after accepted elicitation, add polling interval to description
- Change ALL_SUPPORTED_SCOPES from tuple to frozenset for O(1) lookups
- Replace Optional[str] with str | None throughout config.py
- Use default_factory for ProxyCodeEntry/ASProxySession dataclasses
- Add proxy code/session cleanup to background loop
- Fix OIDC verification CI step to only run for oauth/login-flow modes
- Add unit tests for access.py REST endpoints (10 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 17:22:23 +01:00
Chris Coutinho 0a53aa5fcd ci: enable Playwright browser tests in GitHub Actions
The GITHUB_ACTIONS skip was added before Playwright automation existed,
when tests required manual browser interaction. Now that Playwright
handles the OAuth flow programmatically, the skip is unnecessary —
GitHub Actions fully supports Playwright with localhost networking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 16:09:14 +01:00
Chris Coutinho abd43f8028 ci: disable NC 33 matrix until upstream apps support it
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:57:21 +01:00
Chris Coutinho e7157ab256 fix: skip keycloak hook when profile inactive and update stale PRM test
Add DNS pre-check (getent hosts keycloak) to the post-installation hook
so it exits instantly when the keycloak profile is not active, instead of
retrying for ~2.5 minutes. Also update test_prm_endpoint to assert the
AS proxy URL (localhost:8001) per ADR-023, replacing the stale Nextcloud
URL (localhost:8080).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:50:15 +01:00
github-actions[bot] 08aaa85ab3 bump: version 0.57.91 → 0.57.92 2026-03-02 11:35:44 +00:00
Chris Coutinho ecab777efa Merge pull request #598 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.65
2026-03-02 12:35:28 +01:00
github-actions[bot] c960560716 bump: version 0.57.90 → 0.57.91 2026-03-02 11:33:46 +00:00
Chris Coutinho 023927afff Merge pull request #599 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.47.0
2026-03-02 12:33:28 +01:00
renovate-bot-cbcoutinho[bot] 3a87b33288 chore(deps): update helm release ollama to v1.47.0 2026-03-02 11:15:34 +00:00
renovate-bot-cbcoutinho[bot] c8ebd9c089 chore(deps): update anthropics/claude-code-action action to v1.0.65 2026-03-02 11:15:16 +00:00
Chris Coutinho 5947fff13f chore: revert 2026-03-02 11:28:56 +01:00
Chris Coutinho a9e5c687b8 ci: Ignore oauth and multi-user-basic in integration testing matrix to reduce github ci usage 2026-03-02 11:27:37 +01:00
Chris Coutinho 9d1a84af5a feat(auth): implement OAuth AS proxy to fix audience mismatch (ADR-023)
MCP clients like Claude Code were unable to use tools because tokens
obtained directly from Nextcloud had the wrong audience claim. The MCP
server now acts as its own OAuth Authorization Server, proxying auth
to Nextcloud with its own client_id so tokens have the correct audience.

New endpoints: /.well-known/oauth-authorization-server, /oauth/token,
/oauth/register. Modified /oauth/authorize from pass-through to
intermediary pattern. PRM now points authorization_servers to the MCP
server instead of Nextcloud.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:25:54 +01:00
Chris Coutinho d09ebf20cc feat(ci): add Nextcloud version matrix (NC 31, 32, 33)
- Add cross-product matrix (3 versions x 4 auth modes = 12 CI jobs)
- Parameterize Nextcloud image in docker-compose.yml via NEXTCLOUD_IMAGE env var
- Pin NC 31.0.8, 32.0.6, 33.0.0 with SHA digests in workflow
- Add Renovate customManagers to auto-update NC images in workflow
- Fix Astrolabe install hook to prefer volume mount over app store
- Bump Astrolabe submodule to support NC 33 (max-version 31→33)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 11:13:38 +01:00
Chris Coutinho 0d14c75eb1 fix: address remaining PR #589 review findings
- Consolidate MCP session + login flow cleanup into _mcp_session_with_login_flow() helper,
  replacing 4 duplicated AsyncExitStack sites in app.py
- Fix get_shared_storage() race condition by using module-level anyio.Lock() init
  (reverts regression from ba59763)
- Collapse cosmetic if/else branching in scope_authorization.py
- Consolidate dual password storage paths into single store_app_password_with_scopes() call
- Mark unused request param as _ in list_supported_scopes
- Make ALL_SUPPORTED_SCOPES an immutable tuple; use list() instead of .copy()
- Add hasattr(ctx, "elicit") guard in elicitation.py, narrow except to NotImplementedError
- Add YAML comment explaining --oauth flag for mcp-login-flow service

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:59:56 +01:00
Chris Coutinho ba597634bd fix: address PR #589 review findings
- Fix anyio.Lock() created at module import time; use lazy init in
  get_shared_storage() to avoid instantiation before event loop exists
- Stop get_login_flow_session from silently swallowing DB exceptions;
  re-raise and handle in caller with proper error response
- Update ProvisionAccessResponse and UpdateScopesResponse status field
  docs to include all actual values (declined, cancelled, unchanged)
- Narrow except clause in present_login_url to (AttributeError,
  NotImplementedError) instead of bare Exception
- Add KeyError handling in LoginFlowV2Client.initiate() and poll() for
  clear errors on malformed Nextcloud responses
- Simplify redundant env-var bypass branches in scope_authorization.py
- Extract _maybe_login_flow_cleanup() context manager to replace 4
  inline cleanup loop registrations in app.py; move sleep to end of
  loop body so cleanup runs once at startup
- Replace fragile string replacement in _rewrite_login_flow_url with
  proper urllib.parse URL handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 09:10:57 +01:00
Chris Coutinho 1a6ce0fa7d fix: address PR review issues for Login Flow v2
- Fix circular dependency in scope_authorization: auth tools requiring
  only identity scopes (openid/profile/email) now bypass the login flow
  provisioning check, so unprovisioned users can call provisioning tools
- Fix no-op detection in nc_auth_update_scopes: NULL scopes (legacy "all")
  now correctly map to ALL_SUPPORTED_SCOPES instead of empty set
- Fix get_app_password_with_scopes swallowing exceptions: re-raise instead
  of returning None, matching sibling methods
- Add missing audit logging to update_app_password_scopes,
  delete_login_flow_session, and delete_expired_login_flow_sessions
- Pin setup-uv to v7.3.1 in CI unit-test job (was v7.3.0)
- Add FastMCP type annotation to register_auth_tools parameter
- Log warning when user accepts elicitation without checking acknowledged box

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 19:02:30 +01:00
Chris Coutinho 3df0b06cd1 Merge remote-tracking branch 'origin/master' into feat/docker-compose-profiles-login-flow 2026-03-01 18:52:50 +01:00
Chris Coutinho 0b8afec494 feat(helm): add login-flow auth mode to Helm chart (ADR-022)
Add Login Flow v2 as a fourth auth mode alongside basic, multi-user-basic,
and oauth. This enables multi-user deployments using Nextcloud's native
Login Flow v2 without requiring OAuth patches to user_oidc.

- Add loginFlow section to values.yaml with token encryption config
- Add login-flow env vars, args, volume mounts to deployment.yaml
- Add login-flow secret and oauth-storage PVC templates
- Add loginFlowSecretName helper, update dataStorageEnabled
- Add multi-user-basic and login-flow sections to NOTES.txt
- Add version footer and ArtifactHub changelog annotations
- Update README with 4 auth modes and docker-compose profiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:25:23 +01:00
Chris Coutinho bd69e68dd5 ci: enable Playwright install for multi-user-basic CI job
Astrolabe tests moved to multi_user_basic markers use Playwright browser
automation, so the CI matrix entry needs needs-playwright: true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:01:54 +01:00
Chris Coutinho 148573e28b Merge remote-tracking branch 'origin/master' into feat/docker-compose-profiles-login-flow 2026-03-01 17:26:05 +01:00
github-actions[bot] 5d81d60262 bump: version 0.57.89 → 0.57.90 2026-03-01 16:26:00 +00:00
Chris Coutinho b86e798ba8 Merge pull request #592 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.64
2026-03-01 17:25:47 +01:00
Chris Coutinho a7d623733b Merge pull request #591 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to f3fa41d
2026-03-01 17:25:35 +01:00
github-actions[bot] 3311b20ef6 bump: version 0.57.88 → 0.57.89 2026-03-01 16:24:44 +00:00
Chris Coutinho 28c7f1cdbd Merge pull request #594 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7
2026-03-01 17:24:30 +01:00
github-actions[bot] 2713f74be6 bump: version 0.57.87 → 0.57.88 2026-03-01 16:23:38 +00:00
Chris Coutinho e3c5a87b22 Merge pull request #590 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f
2026-03-01 17:23:22 +01:00
Chris Coutinho 53cf223a56 Merge pull request #595 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.46.0
2026-03-01 17:23:11 +01:00
Chris Coutinho 6bfde0de1f Merge pull request #596 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v7
2026-03-01 17:22:58 +01:00
github-actions[bot] 8cf3264914 bump: version 0.57.86 → 0.57.87 2026-03-01 16:05:13 +00:00
Chris Coutinho ed2f400ed8 Merge pull request #593 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.3.1
2026-03-01 17:04:56 +01:00
renovate-bot-cbcoutinho[bot] 6ba598afd1 chore(deps): update actions/upload-artifact action to v7 2026-03-01 16:03:54 +00:00
renovate-bot-cbcoutinho[bot] d0bfecea97 chore(deps): update helm release ollama to v1.46.0 2026-03-01 16:03:49 +00:00
renovate-bot-cbcoutinho[bot] bf0a4ac5d3 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.7 2026-03-01 16:03:28 +00:00
renovate-bot-cbcoutinho[bot] 3da6feba41 chore(deps): update astral-sh/setup-uv action to v7.3.1 2026-03-01 16:03:20 +00:00
renovate-bot-cbcoutinho[bot] 1224090469 chore(deps): update anthropics/claude-code-action action to v1.0.64 2026-03-01 16:03:13 +00:00
renovate-bot-cbcoutinho[bot] aa624401c3 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to f3fa41d 2026-03-01 16:03:08 +00:00
renovate-bot-cbcoutinho[bot] 61e867397c chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to 5c4e09f 2026-03-01 16:03:02 +00:00
Chris Coutinho db1e0606ad fix: address PR #589 review feedback (round 2)
Consolidate three independent RefreshTokenStorage lazy singletons into a
single lock-protected get_shared_storage() function, eliminating race
conditions on concurrent first-access. Remove blanket try/except in
_get_stored_scopes so storage errors propagate as proper MCP errors
instead of silently triggering "please provision" messages. Handle
declined/cancelled elicitation results in Login Flow tools by cleaning up
sessions and returning clear status. Add update_app_password_scopes() to
avoid unnecessary decrypt/re-encrypt when only scopes change. Add
unprovisioned-user early exit and no-op detection to nc_auth_update_scopes.
Remove four dead config fields and misleading NEXTCLOUD_PASSWORD deprecation
warning. Add periodic login flow session cleanup task. Generate separate
Fernet keys per service. Add board cleanup in deck integration test. Gate
CI unit tests on linting and skip Astrolabe build for single-user profile.
Fix test markers from oauth to multi_user_basic for astrolabe integration
tests. Update login_flow.py docstrings to document outbound HTTP calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:35:31 +01:00
Chris Coutinho 33cf0fee9b fix(ci): remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
The dev OIDC app was mounted for all profiles but composer install only
ran for Playwright profiles, causing a missing vendor/autoload.php fatal
error that broke all /apps/* and /ocs/* routes. The hook script already
has app store fallback logic, so removing the dev mount is sufficient.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:28:01 +01:00
Chris Coutinho b2fd4da9fe fix(ci): fix health check timeout and per-profile MCP server URL routing
The Nextcloud health check expected HTTP 401 from serverinfo, but NC 32
returns 200 — causing 5-minute timeouts. Switch to /status.php with
"installed":true check (matches Docker healthcheck). Also route the
correct MCP_SERVER_URL per CI matrix profile into the app container so
Astrolabe connects to the right service, and make the init script
gracefully skip when the var is unset.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 11:30:07 +01:00
Chris Coutinho 16cd2e27cb fix(ci): fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
PHP setup was gated behind needs-playwright but Astrolabe build needs
composer unconditionally. Add multi-user-basic CI matrix entry with
proper marker filtering. Upload Playwright screenshots and service logs
as artifacts on failure for easier debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 11:02:39 +01:00
Chris Coutinho e28af5453b fix: address PR #589 review feedback for Login Flow v2
- Fix data loss in nc_auth_update_scopes: remove premature
  delete_app_password call; old password stays valid until upsert
  replaces it on successful re-provisioning
- Replace assert with proper error return in nc_auth_check_status
- Add lazy singleton for RefreshTokenStorage in auth_tools,
  scope_authorization, and context to avoid per-call re-initialization
- Centralize _is_login_flow_mode() to get_settings().enable_login_flow
  and remove duplicate definitions and per-call os.getenv reads
- Add dev-only comment to TOKEN_ENCRYPTION_KEY in docker-compose.yml
- Gate OIDC build steps in CI behind matrix.needs-playwright
- Add diagnostic step reporting Playwright skip count in CI

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 10:08:55 +01:00
Chris Coutinho 87ec3c4f5b chore: update third_party submodule pointers
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:17:29 +01:00
Chris Coutinho 989749530c fix(ci): fix integration test collection and skip Playwright in CI
- Add @pytest.mark.oauth to OAuth-dependent tests in
  test_scope_authorization.py so they're excluded from single-user job
- Add module-level pytestmark to test_introspection_authorization.py
- Fix single-user marker expression to also exclude oauth smoke tests
- Add --ignore paths for multi-user, qdrant, and RAG evaluation tests
- Uncomment GITHUB_ACTIONS skip in oauth_callback_server fixture
- Add GITHUB_ACTIONS skip to login_flow_oauth_token fixture
- Mount third_party/oidc volume in docker-compose.yml app service
- Add OIDC diagnostic step in CI for playwright jobs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:17:03 +01:00
Chris Coutinho 2d46959d01 fix(test): fix 17 pre-existing unit test failures and add astrolabe CI build
Unit test fixes:
- test_userinfo_routes: patch nextcloud_httpx_client instead of httpx.AsyncClient
- test_instrument_tool: patch trace_operation in metrics module (where imported)
- test_management_app_password_endpoints: patch nextcloud_httpx_client and
  get_settings at correct import locations
- test_management_status_endpoint: patch detect_auth_mode and get_settings at
  correct import locations (api.management, not config/config_validators)
- test_token_exchange: fix TokenBrokerService constructor args (client_id/
  client_secret instead of encryption_key)

CI:
- Add Node.js setup and astrolabe build step (composer + npm ci + npm run build)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 21:53:06 +01:00
Chris Coutinho 59fdcd123a fix(ci): keep third_party mount, always build submodules in CI
The third_party volume mount is required for astrolabe/notes/oidc
development. Always checkout submodules and build the OIDC app in
all CI matrix jobs since the app container needs it.

Remove the docker-compose.oidc.yml override (no longer needed).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:51:36 +01:00
Chris Coutinho b79c54cc6a fix(ci): revert accidental third_party mount, use compose override for OIDC
The third_party:/opt/apps volume was accidentally uncommented in
docker-compose.yml. Without submodules checked out, this empty mount
breaks the Notes app installation hook in CI.

Fix: keep the mount commented in docker-compose.yml and add a separate
docker-compose.oidc.yml override that's only used for OIDC-requiring
profiles (oauth, login-flow) in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:48:07 +01:00
Chris Coutinho fe3fbe95a1 fix(ci): don't block integration matrix on unit-test failures
Unit tests have pre-existing failures unrelated to deployment mode
testing. Run integration matrix after linting only so the matrix
can expand and test each profile independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:39:45 +01:00
Chris Coutinho 8fe7d81e57 ci: use matrix strategy for deployment mode integration tests
Replace the single integration-test job with a matrix that tests each
deployment mode independently using Docker Compose profiles:

- single-user: smoke + integration tests (port 8000)
- oauth: OAuth flow tests with Playwright (port 8001)
- login-flow: Login Flow v2 tests with Playwright (port 8004)

Unit tests run separately without Docker. OIDC app build and Playwright
install are conditional based on the mode. Service logs are captured on
failure for debugging.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:35:47 +01:00
Chris Coutinho 8b5c2395b5 feat: add Docker Compose profiles and Login Flow v2 service
Add selective service startup via Docker Compose profiles so each MCP
deployment mode runs independently. Also add the new mcp-login-flow
service (port 8004) for Login Flow v2 authentication (ADR-022).

Profile assignments:
- single-user: mcp (port 8000)
- multi-user-basic: mcp-multi-user-basic (port 8003)
- oauth: mcp-oauth (port 8001)
- keycloak: keycloak + mcp-keycloak (port 8002)
- login-flow: mcp-login-flow (port 8004)

Infrastructure services (db, redis, app, recipes) always start.

Integration tests cover the full Login Flow v2 provisioning flow:
OAuth → browser login → app password → Nextcloud API access for
notes, calendar, contacts, files, deck, and cookbook operations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 20:33:54 +01:00
github-actions[bot] 5796e2ba54 bump: version 0.57.85 → 0.57.86 2026-02-26 15:42:35 +00:00
github-actions[bot] 37141ea79f bump: version 0.64.3 → 0.64.4 2026-02-26 15:42:35 +00:00
Chris Coutinho 68126f6fe3 Merge pull request #555 from cbcoutinho/renovate/icalendar-7.x
fix(deps): update dependency icalendar to v7
2026-02-26 16:42:12 +01:00
github-actions[bot] 78b934ffa6 bump: version 0.57.84 → 0.57.85 2026-02-25 12:43:46 +00:00
Chris Coutinho 01a9ad5278 Merge pull request #588 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.60
2026-02-25 13:43:30 +01:00
github-actions[bot] b67a566902 bump: version 0.57.83 → 0.57.84 2026-02-25 11:35:38 +00:00
Chris Coutinho c9e8a56355 Merge pull request #584 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.6
chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60
2026-02-25 12:35:22 +01:00
renovate-bot-cbcoutinho[bot] 785ba5bf09 chore(deps): update anthropics/claude-code-action action to v1.0.60 2026-02-25 11:18:33 +00:00
renovate-bot-cbcoutinho[bot] 159ffb6110 chore(deps): update docker.io/library/nextcloud:32.0.6 docker digest to dcf9c60 2026-02-25 11:18:25 +00:00
github-actions[bot] 70139c4782 bump: version 0.57.82 → 0.57.83 2026-02-25 09:14:58 +00:00
Chris Coutinho a922187489 Merge pull request #586 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.59
2026-02-25 10:14:43 +01:00
github-actions[bot] 1ba6a142f5 bump: version 0.57.81 → 0.57.82 2026-02-25 08:42:21 +00:00
Chris Coutinho 79478f2483 Merge pull request #585 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 39e4e1c
2026-02-25 09:42:03 +01:00
github-actions[bot] 4721a5da52 bump: version 0.57.80 → 0.57.81 2026-02-25 07:26:59 +00:00
Chris Coutinho be2b683604 Merge pull request #587 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6
2026-02-25 08:26:45 +01:00
renovate-bot-cbcoutinho[bot] 9fd3d92a0f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.6 2026-02-25 05:15:47 +00:00
renovate-bot-cbcoutinho[bot] ceebda071f chore(deps): update anthropics/claude-code-action action to v1.0.59 2026-02-25 05:15:42 +00:00
renovate-bot-cbcoutinho[bot] 26fc48dc46 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 39e4e1c 2026-02-25 05:15:37 +00:00
renovate-bot-cbcoutinho[bot] 3edc226d17 fix(deps): update dependency icalendar to v7 2026-02-24 17:17:49 +00:00
github-actions[bot] 7384b47795 bump: version 0.57.79 → 0.57.80 2026-02-24 12:44:15 +00:00
Chris Coutinho b62d275dc9 Merge pull request #583 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.57
2026-02-24 13:43:57 +01:00
renovate-bot-cbcoutinho[bot] a0fa0230ab chore(deps): update anthropics/claude-code-action action to v1.0.57 2026-02-24 11:15:33 +00:00
github-actions[bot] 7314097483 bump: version 0.57.78 → 0.57.79 2026-02-24 09:34:47 +00:00
Chris Coutinho 3d070f74c5 Merge pull request #581 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5
2026-02-24 10:34:30 +01:00
github-actions[bot] 80366a4e1e bump: version 0.57.77 → 0.57.78 2026-02-24 08:21:58 +00:00
Chris Coutinho 91941a9ece Merge pull request #582 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.56
2026-02-24 09:21:39 +01:00
github-actions[bot] 8fd6f4158f bump: version 0.57.76 → 0.57.77 2026-02-24 08:19:59 +00:00
Chris Coutinho b8e6539b6f Merge pull request #580 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to ba6cb07
2026-02-24 09:19:15 +01:00
github-actions[bot] fe53e93fe9 bump: version 0.57.75 → 0.57.76 2026-02-24 07:51:52 +00:00
Chris Coutinho 71d4c44b05 Merge pull request #579 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59
2026-02-24 08:51:37 +01:00
renovate-bot-cbcoutinho[bot] 8261048741 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.5 2026-02-24 05:14:52 +00:00
renovate-bot-cbcoutinho[bot] 6443aca743 chore(deps): update anthropics/claude-code-action action to v1.0.56 2026-02-24 05:14:46 +00:00
renovate-bot-cbcoutinho[bot] a1b5e676e9 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to ba6cb07 2026-02-23 23:14:33 +00:00
renovate-bot-cbcoutinho[bot] 1d9168f614 chore(deps): update docker.io/library/redis:alpine docker digest to 2afba59 2026-02-23 23:14:28 +00:00
github-actions[bot] 9229440a58 bump: version 0.57.74 → 0.57.75 2026-02-23 06:08:54 +00:00
Chris Coutinho e507f29e83 Merge pull request #578 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.45.0
2026-02-23 07:08:40 +01:00
renovate-bot-cbcoutinho[bot] 5ac6d8d396 chore(deps): update helm release ollama to v1.45.0 2026-02-23 05:14:16 +00:00
github-actions[bot] ab71003c5d bump: version 0.57.73 → 0.57.74 2026-02-21 08:53:06 +00:00
Chris Coutinho 726b71eea1 Merge pull request #575 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.4
2026-02-21 09:52:51 +01:00
github-actions[bot] 3e50924169 bump: version 0.57.72 → 0.57.73 2026-02-21 08:38:08 +00:00
github-actions[bot] b2773317ef bump: version 0.64.2 → 0.64.3 2026-02-21 08:38:07 +00:00
Chris Coutinho dce3ca9a70 Merge pull request #574 from cbcoutinho/fix/wrap-list-returns-in-response-models
fix: wrap raw list returns in response models (#568)
2026-02-21 09:37:48 +01:00
Chris Coutinho 18e5baf2a5 fix: address PR #574 fourth review round
- Use lowercase generics (list[...]) in new deck response models
- Add clarifying comment on AddressBook.uri slug semantics
- Fall back calendar_display_name to calendar_name when absent

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

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

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

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

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

Closes #568

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Related: #521

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

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

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

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

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

Closes #560

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

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

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

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

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

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

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

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

Closes #544

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

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

Closes #538

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

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

Closes #538

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 17:35:33 +01:00
github-actions[bot] 334d62825c bump: version 0.57.38 → 0.57.39 2026-02-07 14:49:39 +00:00
Chris Coutinho 2233cb423c Merge pull request #537 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 9e01bf1
2026-02-07 15:49:23 +01:00
github-actions[bot] 196a6cdfb2 bump: version 0.57.37 → 0.57.38 2026-02-07 14:47:36 +00:00
Chris Coutinho 93f5e70128 Merge pull request #519 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.3.0
2026-02-07 15:47:20 +01:00
renovate-bot-cbcoutinho[bot] e5248e70ee chore(deps): update astral-sh/setup-uv action to v7.3.0 2026-02-07 11:10:43 +00:00
renovate-bot-cbcoutinho[bot] 018b946b5b chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 9e01bf1 2026-02-07 11:10:27 +00:00
github-actions[bot] 863ba0d52a bump: version 0.57.36 → 0.57.37 2026-02-06 20:08:06 +00:00
Chris Coutinho d3903c5e2e Merge pull request #529 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.41.0
2026-02-06 21:07:48 +01:00
github-actions[bot] 6ea97c5b88 bump: version 0.57.35 → 0.57.36 2026-02-06 17:44:50 +00:00
Chris Coutinho c12c825b11 Merge pull request #530 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.5.0
2026-02-06 18:44:32 +01:00
github-actions[bot] 3d8f7692a8 bump: version 0.57.34 → 0.57.35 2026-02-06 15:18:18 +00:00
Chris Coutinho b21c874c14 Merge pull request #531 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 5878d06
2026-02-06 16:18:00 +01:00
github-actions[bot] a4661099e5 bump: version 0.57.33 → 0.57.34 2026-02-06 14:49:36 +00:00
Chris Coutinho a46d74d999 Merge pull request #522 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.45
2026-02-06 15:49:19 +01:00
github-actions[bot] 92f69c8dba bump: version 0.57.32 → 0.57.33 2026-02-06 14:23:11 +00:00
Chris Coutinho 6692a85007 Merge pull request #534 from cbcoutinho/renovate/uv_build-0.x
chore(deps): update dependency uv_build to >=0.10.0,<0.11.0
2026-02-06 15:22:55 +01:00
github-actions[bot] 1f09079b5a bump: version 0.57.31 → 0.57.32 2026-02-06 14:04:59 +00:00
Chris Coutinho 2535c95f4e Merge pull request #535 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.0
2026-02-06 15:04:30 +01:00
renovate-bot-cbcoutinho[bot] 4fac0ca40d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.0 2026-02-06 11:09:24 +00:00
renovate-bot-cbcoutinho[bot] 719a432a95 chore(deps): update dependency uv_build to >=0.10.0,<0.11.0 2026-02-06 11:09:13 +00:00
renovate-bot-cbcoutinho[bot] 14c4512ef8 chore(deps): update anthropics/claude-code-action action to v1.0.45 2026-02-06 11:08:46 +00:00
github-actions[bot] 6f482c9245 bump: version 0.57.30 → 0.57.31 2026-02-06 07:23:12 +00:00
Chris Coutinho a6ad3707c6 Merge pull request #513 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.30
2026-02-06 08:22:55 +01:00
github-actions[bot] b34f8d96e3 bump: version 0.57.29 → 0.57.30 2026-02-06 07:09:22 +00:00
Chris Coutinho d948f51b10 Merge pull request #532 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 43e4d70
2026-02-06 08:09:07 +01:00
renovate-bot-cbcoutinho[bot] 5eb5b5023c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.30 2026-02-05 11:12:00 +00:00
renovate-bot-cbcoutinho[bot] 504213ae79 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 43e4d70 2026-02-05 11:11:49 +00:00
renovate-bot-cbcoutinho[bot] 5eeaafbe95 chore(deps): update docker.io/library/nginx:alpine docker digest to 5878d06 2026-02-05 11:11:43 +00:00
renovate-bot-cbcoutinho[bot] 0ddc62c371 chore(deps): update hoverkraft-tech/compose-action action to v2.5.0 2026-02-04 11:09:33 +00:00
renovate-bot-cbcoutinho[bot] 36d901d5ae chore(deps): update helm release ollama to v1.41.0 2026-02-04 11:09:28 +00:00
renovate-bot-cbcoutinho[bot] 119a422a35 chore(deps): update actions/checkout digest to de0fac2 2026-02-04 11:08:12 +00:00
github-actions[bot] 0a3052d0d9 bump: version 0.57.28 → 0.57.29 2026-02-04 06:25:10 +00:00
Chris Coutinho 2b691f1792 Merge pull request #525 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.5
chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 4b66e9b
2026-02-04 07:24:55 +01:00
github-actions[bot] e3da2e006c bump: version 0.57.27 → 0.57.28 2026-02-03 19:57:46 +00:00
Chris Coutinho 4539f2f486 Merge pull request #526 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 87b49ee
2026-02-03 20:57:27 +01:00
renovate-bot-cbcoutinho[bot] c85ad95faf chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 87b49ee 2026-02-03 11:12:24 +00:00
renovate-bot-cbcoutinho[bot] 60f7234908 chore(deps): update docker.io/library/nextcloud:32.0.5 docker digest to 4b66e9b 2026-02-03 11:12:18 +00:00
github-actions[bot] 1dd5698389 bump: version 0.10.0 → 0.10.1 2026-02-03 06:50:26 +00:00
github-actions[bot] 3a0096f8df bump: version 0.57.26 → 0.57.27 2026-02-03 06:50:26 +00:00
github-actions[bot] 7bcffd1e96 bump: version 0.63.0 → 0.63.1 2026-02-03 06:50:25 +00:00
Chris Coutinho 9674366312 Merge pull request #524 from rule88/master
create persistant volume in basis auth as well
2026-02-03 07:50:06 +01:00
Chris Coutinho a7581a1d1b fix(helm): add backward compatibility for legacy persistence configs
- Add helper functions to detect and use legacy persistence configs
- Legacy auth.multiUserBasic.persistence.* and qdrant.localPersistence.*
  configs continue to work but show deprecation warnings in NOTES.txt
- New dataStorage.enabled takes precedence when explicitly set
- PVC size/accessMode/storageClass values from legacy configs are honored

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

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

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

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

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

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

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

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

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

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

Closes #510

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes 401 errors when searching in Astrolabe with hybrid deployment.

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

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

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

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

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

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

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

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

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

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

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

Also add webhook management documentation guide.

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

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

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

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

Addresses reviewer feedback on PR #473.

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

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

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

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

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

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

Addresses reviewer feedback on PR #473 requiring test coverage.

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

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

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

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

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

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

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

Fixes #469

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:29:03 +01:00
Chris Coutinho 817df43af1 Merge pull request #465 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9
2026-01-10 21:40:04 +01:00
Chris Coutinho 906b9d892c Merge pull request #467 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24
2026-01-10 21:39:50 +01:00
Chris Coutinho 534723c9f6 Merge pull request #466 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37
2026-01-10 21:39:26 +01:00
renovate-bot-cbcoutinho[bot] 1d5832ed3a chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.24 2026-01-10 11:10:53 +00:00
renovate-bot-cbcoutinho[bot] 844bd589e0 chore(deps): update docker.io/library/nginx:alpine docker digest to c083c37 2026-01-10 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 127af15623 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 271a9c9 2026-01-10 11:10:43 +00:00
Chris Coutinho ff5fc5d5b2 Merge pull request #427 from cbcoutinho/renovate/icewind1991-nextcloud-version-matrix-1.x
chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1
2026-01-09 12:40:16 +01:00
Chris Coutinho 158865d99f Merge pull request #463 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 1b8ee3b
2026-01-09 12:30:25 +01:00
Chris Coutinho 94674eca27 Update .github/workflows/claude.yml 2026-01-09 12:30:20 +01:00
Chris Coutinho a8b5d6e701 Update .github/workflows/claude-code-review.yml 2026-01-09 12:30:14 +01:00
Chris Coutinho e0675b2127 Merge pull request #405 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795
2026-01-09 12:28:48 +01:00
Chris Coutinho 86582bdb8f Merge pull request #464 from cbcoutinho/renovate/docker.io-qdrant-qdrant-1.x
chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3
2026-01-09 12:28:29 +01:00
renovate-bot-cbcoutinho[bot] dc8009a785 chore(deps): update docker.io/qdrant/qdrant docker tag to v1.16.3 2026-01-09 11:14:26 +00:00
renovate-bot-cbcoutinho[bot] b5e658e1ff chore(deps): update docker.io/library/nginx:alpine docker digest to 8491795 2026-01-09 11:14:22 +00:00
renovate-bot-cbcoutinho[bot] 6a19c2d136 chore(deps): update anthropics/claude-code-action digest to 1b8ee3b 2026-01-09 11:14:15 +00:00
Chris Coutinho 99e359ffbf Merge pull request #374 from cbcoutinho/renovate/qdrant-qdrant-replacement
chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant
2026-01-08 13:17:17 +01:00
Chris Coutinho f16f4e8cb5 Merge pull request #461 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8
2026-01-08 13:17:03 +01:00
Chris Coutinho 8597f2a272 Merge pull request #462 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.37.0
2026-01-08 13:16:35 +01:00
renovate-bot-cbcoutinho[bot] 11f67e2bc4 chore(deps): update helm release ollama to v1.37.0 2026-01-08 11:11:18 +00:00
renovate-bot-cbcoutinho[bot] 2e49a16e49 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to db5fcc8 2026-01-08 11:10:49 +00:00
renovate-bot-cbcoutinho[bot] 713fddeaa5 chore(deps): replace qdrant/qdrant docker tag with docker.io/qdrant/qdrant 2026-01-08 11:10:22 +00:00
Chris Coutinho 0dfefb0516 Merge pull request #459 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.3
2026-01-07 19:55:20 +01:00
Chris Coutinho 63d2aeaa43 Merge pull request #460 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.2.0
2026-01-07 19:52:15 +01:00
renovate-bot-cbcoutinho[bot] 07f0a7c0dc chore(deps): update astral-sh/setup-uv action to v7.2.0 2026-01-07 11:12:25 +00:00
renovate-bot-cbcoutinho[bot] 84bde6d5ed chore(deps): update hoverkraft-tech/compose-action action to v2.4.3 2026-01-07 11:12:10 +00:00
Chris Coutinho 9695f8a6d7 Merge pull request #457 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to c9ec2b0
2026-01-07 08:29:06 +01:00
Chris Coutinho a2c410e8d2 Merge pull request #458 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0
2026-01-07 08:14:01 +01:00
Chris Coutinho 271b5f6155 Merge pull request #451 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22
2026-01-07 07:48:28 +01:00
renovate-bot-cbcoutinho[bot] ba4f7c1429 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.0 2026-01-06 11:11:19 +00:00
renovate-bot-cbcoutinho[bot] c763e96596 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.22 2026-01-06 11:10:52 +00:00
renovate-bot-cbcoutinho[bot] 23e9cbaec5 chore(deps): update anthropics/claude-code-action digest to c9ec2b0 2026-01-06 11:10:45 +00:00
Chris Coutinho ddd5defa40 Merge pull request #406 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9
2026-01-03 13:17:23 -06:00
Chris Coutinho 723dcc524d Merge pull request #450 from cbcoutinho/renovate/docker.io-library-python-3.12-slim-trixie
chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d
2026-01-03 09:05:41 -06:00
Chris Coutinho 46eba0a693 Merge pull request #456 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf
2026-01-03 09:05:29 -06:00
renovate-bot-cbcoutinho[bot] b61980a623 chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to efbcfaf 2026-01-03 11:12:11 +00:00
renovate-bot-cbcoutinho[bot] 65cc894e21 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to a75662d 2026-01-03 11:12:06 +00:00
renovate-bot-cbcoutinho[bot] 700996e100 chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 0ddb0f9 2026-01-02 11:10:19 +00:00
github-actions[bot] 546f0c0674 bump: version 0.60.2 → 0.60.3 2025-12-31 05:37:15 +00:00
Chris Coutinho e625eab689 Merge pull request #453 from cbcoutinho/fix/452
fix: DeckClient.update_card partial update bugs
2025-12-30 23:36:57 -06:00
Chris Coutinho a26a470af6 fix(deck): Always preserve fields in update_card for partial updates
The Deck PUT API is a full replacement, not a partial update.
Previously, title and description were conditionally sent, causing:
- 400 errors when title not provided (it's required)
- Description being cleared when not explicitly set

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

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

Fixes #452

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

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

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

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

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

Related: #452

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 21:48:26 +01:00
github-actions[bot] 541f7a6abd bump: version 0.54.0 → 0.55.0 2025-12-22 20:35:14 +00:00
github-actions[bot] 28cfee4bab bump: version 0.58.0 → 0.59.0 2025-12-22 20:35:13 +00:00
Chris Coutinho 358d962822 Merge pull request #447 from cbcoutinho/feature/helm-chart-multi-user-basic-support
feat(helm): add multi-user BasicAuth mode support
2025-12-22 21:34:53 +01:00
Chris Coutinho ea96a58678 fix(helm): address PR #447 reviewer feedback
Critical fix:
- deployment.yaml: Only reference OAuth credentials when clientId is set
- Fixes pod failure when using existingSecret without static OAuth creds
- Aligns deployment behavior with secret template logic

Previously, the deployment referenced OAuth credentials when either
clientId OR existingSecret was set. However, the secret template only
includes OAuth credentials when clientId is explicitly provided. This
caused pod failures when users provided an existingSecret for offline
access without static OAuth credentials (intending to use DCR).

The fix ensures OAuth env vars are only referenced when clientId is set,
matching the OAuth mode pattern and allowing DCR to work correctly with
existingSecret configurations.

Minor improvements:
- values.yaml: Clarify OAuth credentials are optional (uses DCR if not provided)

Testing verified all scenarios:
 Pass-through only (no offline access): No secrets/PVCs/OAuth vars
 Offline + DCR (no clientId): Secret with encryption key only, no OAuth vars
 Offline + static OAuth: Secret with all keys, OAuth vars present
 existingSecret without clientId: No auto secret, no OAuth vars (FIXED)

Resolves reviewer feedback from PR #447
2025-12-22 21:34:40 +01:00
Chris Coutinho 9b5c6779e9 fix(helm): include MCP server version bumps in changelog pattern
Updates helm chart commitizen config to recognize MCP server version
bump commits (which update appVersion in Chart.yaml) as valid triggers
for helm chart version bumps.

Problem:
- When MCP server version bumps, it updates Chart.yaml appVersion
- Helm chart commitizen only matched "(helm)" scoped commits
- Result: appVersion updated but chart version not bumped

Solution:
- Extended changelog_pattern to include "bump: version X → Y" commits
- Now helm chart version will bump when either:
  1. Commits with (helm) scope are made, OR
  2. MCP server version bumps (updating appVersion)

This ensures chart version stays in sync with appVersion updates.
2025-12-22 21:17:14 +01:00
Chris Coutinho 04140d671e feat(helm): add support for multi-user BasicAuth mode
Adds multi-user-basic authentication mode to the helm chart alongside
existing basic (single-user) and oauth modes.

Multi-user BasicAuth mode enables:
- Pass-through authentication (credentials in request headers)
- Optional background operations using app passwords via Astrolabe
- Optional OAuth client credentials (uses DCR if not provided)
- Token encryption and persistent storage for background sync

Changes:
- values.yaml: Add auth.multiUserBasic configuration section
- deployment.yaml: Add ENABLE_MULTI_USER_BASIC_AUTH and related env vars
- secret.yaml: Add secret template for token encryption key and OAuth credentials
- pvc.yaml: Add PVC template for token database persistence
- _helpers.tpl: Add helper functions for secret/PVC names

Tested with:
  helm template --set auth.mode=multi-user-basic \
    --set auth.multiUserBasic.enableOfflineAccess=true \
    --set auth.multiUserBasic.tokenEncryptionKey=... \
    --set vectorSync.enabled=true

Related: Multi-user deployment support (ADR-020)
2025-12-22 21:03:10 +01:00
github-actions[bot] ff8828e972 bump: version 0.5.0 → 0.6.0 2025-12-22 18:49:32 +00:00
github-actions[bot] 43c7421d28 bump: version 0.57.0 → 0.58.0 2025-12-22 18:49:31 +00:00
Chris Coutinho e49dc2bfc4 Merge pull request #445 from cbcoutinho/feature/config-consolidation-adr-021
feat(config): consolidate configuration with smart dependency resolution (ADR-021)
2025-12-22 19:49:13 +01:00
Chris Coutinho 4a5766b84e feat(config): enable DCR for multi-user BasicAuth with offline access
Allows multi-user BasicAuth mode to use Dynamic Client Registration (DCR)
for OAuth credentials when ENABLE_OFFLINE_ACCESS is enabled, making it
consistent with OAuth modes and reducing configuration burden.

**Changes:**

Configuration Validation:
- Relaxed OAuth credential requirements for multi-user BasicAuth
- OAuth credentials now optional when offline access enabled
- Will use DCR as fallback if NEXTCLOUD_OIDC_CLIENT_ID/SECRET not set
- Updated validation to log info instead of error when DCR will be used

Startup Logic (app.py):
- Added DCR workflow for multi-user BasicAuth before uvicorn starts
- Creates oauth_context for management APIs when offline access enabled
- Allows Astrolabe to authenticate management API calls with OAuth
- DCR runs synchronously at same lifecycle point as OAuth modes
- Added traceback import for better error logging
- Fixed type assertions for nextcloud_host
- Fixed undefined variable references in vector sync logging

Management API:
- Improved auth mode detection using proper detect_auth_mode()
- Added auth_mode field to /status endpoint:
  * "basic" - Single-user BasicAuth
  * "multi_user_basic" - Multi-user BasicAuth
  * "oauth" - OAuth modes
  * "smithery" - Smithery stateless
- Added supports_app_passwords indicator for multi-user BasicAuth

Docker Compose:
- Updated mcp-multi-user-basic service configuration:
  * Enabled vector sync (VECTOR_SYNC_ENABLED=true)
  * Added ENABLE_OFFLINE_ACCESS=true for app password support
  * Added NEXTCLOUD_MCP_SERVER_URL for Astrolabe integration
  * Documented optional static OAuth credentials

Testing:
- Updated test_config_validators.py to expect DCR fallback
- Enhanced configure_astrolabe_for_mcp_server fixture with verification
- Added debug logging to test_users_setup fixture

**Workflow:**
1. User configures ENABLE_OFFLINE_ACCESS=true
2. Server checks for static NEXTCLOUD_OIDC_CLIENT_ID/SECRET
3. If not found, performs DCR before uvicorn starts
4. DCR registers client with Nextcloud OIDC provider
5. OAuth credentials used for Astrolabe management API auth
6. Background sync can retrieve user app passwords via Astrolabe

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 19:43:24 +01:00
Chris Coutinho 65c3f099fa feat(astrolabe): implement app password provisioning for multi-user background sync
Adds complete app password provisioning workflow for multi-user BasicAuth
deployments, allowing users to independently enable background sync by
generating and storing Nextcloud app passwords.

**New Components:**

Backend (PHP):
- CredentialsController: Validates and stores app passwords
  * Validates app password format and authenticity via OCS API
  * Stores encrypted passwords in oc_preferences
  * Provides status and credential management endpoints
- AstrolabeAdminSettings: Admin configuration page for MCP server URL
- AstrolabeAdminSettingsListener: Event listener for admin section
- Updated McpTokenStorage: Added background sync credential methods

Frontend:
- personalSettings.js: Form handling for app password entry
  * AJAX submission with error handling
  * Shows success/error notifications
  * Triggers page reload after successful save
- settings.css: Styling for settings pages
- Updated personal.php template: Two-option UI
  * Option 1: OAuth refresh token (future, not yet available)
  * Option 2: App password (works today, recommended)
  * Shows "Active" badge when provisioned
  * Displays credential type and provisioned timestamp

Routes:
- POST /api/v1/background-sync/credentials - Store app password
- GET /api/v1/background-sync/status - Get provisioning status
- DELETE /api/v1/background-sync/credentials - Revoke credentials
- GET /api/v1/background-sync/credentials/{userId} - Admin only

**Testing:**
- test_astrolabe_settings_buttons.py: Integration test for UI buttons

**Workflow:**
1. User generates app password in Nextcloud Security settings
2. User navigates to Astrolabe personal settings
3. User enters app password in "Option 2: App Password" form
4. Backend validates password via OCS API call
5. Password stored encrypted in oc_preferences
6. Page reloads showing "Active" badge with credential details
7. MCP server can now use stored password for background operations

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 19:39:13 +01:00
Chris Coutinho b293258210 test(astrolabe): fix app password extraction in multi-user background sync test
Fixes the Playwright-based integration test that verifies multi-user app
password provisioning for background sync in Astrolabe.

**Root Cause:**
The test was failing to extract the generated app password from Nextcloud's
"New app password" dialog due to overly specific CSS selectors that didn't
match the actual DOM structure.

**Changes:**
- Enhanced network response logging to capture HTTP status codes
- Simplified app password extraction logic:
  * Wait for dialog heading using text selector
  * Iterate through ALL text inputs on page
  * Find password by pattern: contains dashes and length > 20
  * Validate extracted password against expected format
- Added format validation with regex before returning password
- Added detailed debug logging for each extraction step
- Improved error messages with screenshot paths

**Testing:**
Test now successfully completes for both alice and bob test users:
- Logs in to Nextcloud
- Generates app password in Security settings
- Extracts password from dialog
- Navigates to Astrolabe settings
- Enters and saves app password
- Verifies "Active" badge appears
- Confirms credentials stored in database

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-22 19:32:06 +01:00
Chris Coutinho 8f83034c79 Merge pull request #446 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.3
2025-12-22 13:21:33 +01:00
renovate-bot-cbcoutinho[bot] d195fc43d2 chore(deps): update helm release qdrant to v1.16.3 2025-12-22 11:09:45 +00:00
Chris Coutinho 1a5bb10cd0 feat(config): consolidate configuration with smart dependency resolution (ADR-021)
Simplifies configuration by consolidating overlapping settings and adding
automatic dependency resolution. This makes semantic search configuration
significantly easier for users while maintaining 100% backward compatibility.

## Key Changes

### Variable Renaming (Backward Compatible)
- `VECTOR_SYNC_ENABLED` → `ENABLE_SEMANTIC_SEARCH` (old name still works)
- `ENABLE_OFFLINE_ACCESS` → `ENABLE_BACKGROUND_OPERATIONS` (old name still works)
- Deprecation warnings logged when old names used
- Old names will be removed in v1.0.0

### Smart Dependency Resolution
- `ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes
- No need to set both `ENABLE_OFFLINE_ACCESS` and `VECTOR_SYNC_ENABLED` anymore
- Single-user mode doesn't auto-enable background ops (not needed)

### Explicit Mode Selection (Optional)
- New `MCP_DEPLOYMENT_MODE` environment variable
- Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
  oauth_token_exchange, smithery
- Removes ambiguity about which deployment mode is active
- Falls back to auto-detection if not set (existing behavior)

### Configuration Templates
- Reorganized `env.sample` by deployment mode with clear sections
- Added mode-specific quick-start templates:
  - `env.sample.single-user` - Simplest configuration
  - `env.sample.oauth-multi-user` - Recommended multi-user
  - `env.sample.oauth-advanced` - Token exchange mode

## Implementation Details

### Files Modified
- `nextcloud_mcp_server/config.py` - Smart dependency resolution helpers
- `nextcloud_mcp_server/config_validators.py` - Simplified validation, explicit mode
- `tests/unit/test_config_validators.py` - 19 new tests (60 total, all passing)
- `env.sample` - Reorganized by deployment mode
- `docs/configuration.md` - Complete rewrite with consolidated approach
- `docs/troubleshooting.md` - New consolidation troubleshooting section
- `README.md` - Updated variable references

### New Files
- `docs/ADR-021-configuration-consolidation.md` - Architecture decision record
- `docs/configuration-migration-v2.md` - Comprehensive migration guide
- `env.sample.single-user` - Single-user quick-start template
- `env.sample.oauth-multi-user` - OAuth multi-user quick-start template
- `env.sample.oauth-advanced` - Token exchange quick-start template

## User Impact

### Before (Confusing)
```bash
ENABLE_OFFLINE_ACCESS=true      # Why both?
VECTOR_SYNC_ENABLED=true        # What's the relationship?
```

### After (Simplified)
```bash
MCP_DEPLOYMENT_MODE=oauth_single_audience  # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true                # Auto-enables background ops!
```

### Benefits
- 📉 2 fewer variables to understand for semantic search
- 📋 Clear intent ("I want semantic search")
- 🎯 Explicit mode declaration available
- 🔄 100% backward compatible
-  All 265 unit tests passing

## Testing
- All 60 config validation tests passing
- 10 new tests for configuration consolidation
- 9 new tests for explicit mode selection
- Full unit test suite: 265 tests passing
- Backward compatibility verified

## Migration
Users can migrate at their own pace. Old variable names continue working
with deprecation warnings. See docs/configuration-migration-v2.md for
detailed migration instructions.

Related: ADR-021

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 20:36:36 +01:00
renovate-bot-cbcoutinho[bot] a987643f8e chore(deps): update icewind1991/nextcloud-version-matrix action to v1.3.1 2025-12-21 11:09:21 +00:00
github-actions[bot] 34273ec01e bump: version 0.4.4 → 0.5.0 2025-12-20 20:42:30 +00:00
github-actions[bot] fd7f33943d bump: version 0.56.2 → 0.57.0 2025-12-20 20:42:29 +00:00
Chris Coutinho ecaa1f8f01 Merge pull request #443 from cbcoutinho/feature/multi-user-deployment
refactor(config): centralize configuration validation and add dynamic Astrolabe testing
2025-12-20 21:42:10 +01:00
Chris Coutinho 981f102b27 fix(config): address reviewer feedback
- Restore CI test filter (-m unit -m smoke) for faster CI runs
- Replace local path reference with ADR-020 reference in config_validators.py
- Add comprehensive BasicAuthMiddleware unit tests (10 tests covering all edge cases)

Addresses critical CI issue and improves test coverage for multi-user BasicAuth mode.
2025-12-20 21:16:17 +01:00
Chris Coutinho 94febf1602 ci(test): build Astrolabe app before running tests
Add build step for Astrolabe app in CI workflow to compile frontend
assets before docker-compose starts.

Changes:
- Install Node.js 20 for Astrolabe build
- Run composer install --no-dev for Astrolabe PHP dependencies
- Run npm ci and npm run build to compile frontend assets

This ensures the Astrolabe app is properly built in CI, similar to
the existing OIDC app build process.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 21:11:28 +01:00
Chris Coutinho 286a3eb20f feat(auth): add multi-user BasicAuth pass-through mode
Implement multi-user BasicAuth pass-through mode (ADR-020) where each
request includes BasicAuth credentials that are forwarded to Nextcloud
APIs without persistent storage.

Changes:
- Add _get_client_from_basic_auth() in context.py to extract credentials
  from Authorization header (set by BasicAuthMiddleware)
- Add AstrolabeClient for app password provisioning via Astrolabe API
- Update oauth_sync.py with dual credential support (app passwords first,
  then refresh tokens as fallback)
- Simplify oauth_tools.py provisioning logic
- Add integration tests for app password provisioning and multi-user BasicAuth

Features:
- Stateless multi-user mode: credentials passed per-request
- Optional background sync via app passwords (stored in Astrolabe)
- Falls back to refresh tokens if app password not available
- Test coverage for provisioning flow and pass-through mode

Related: ADR-019 (Multi-user BasicAuth), ADR-020 (Deployment Modes)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:55:31 +01:00
Chris Coutinho 19b209f412 test: Enable all tests 2025-12-20 20:53:02 +01:00
Chris Coutinho cd7ba5685a feat(astrolabe): add dynamic MCP server configuration for testing
Replace static post-installation configuration with dynamic test-time
configuration to support testing multiple MCP server deployments.

Changes:
- Remove static MCP server URL and OAuth client setup from post-installation
- Add configure_astrolabe_for_mcp_server fixture (session-scoped)
- Fixture dynamically configures:
  * Nextcloud system config (mcp_server_url, mcp_server_public_url)
  * OAuth client creation via occ oidc:create
  * Client credential storage (astrolabe_client_id, astrolabe_client_secret)
- Update existing OAuth tests to use dynamic configuration
- Add test_astrolabe_multi_server_integration.py with parametrized tests

Benefits:
- Test Astrolabe with mcp-oauth, mcp-keycloak, mcp-multi-user-basic
- Each test configures for its specific MCP server
- No static configuration conflicts between deployments
- Cleaner post-installation (37 lines, down from 85)

Test Results:
- test_astrolabe_configuration_for_different_servers: PASSED (mcp-oauth, mcp-keycloak)
- test_astrolabe_reconfiguration: PASSED

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:49:53 +01:00
Chris Coutinho 4507359760 refactor(config): centralize configuration validation and simplify startup
Implement centralized configuration validation (ADR-020) to simplify
deployment mode detection and improve error messages.

Changes:
- Create ADR-020 documenting 5 deployment modes with required/optional config
- Add config_validators.py with validate_configuration() and mode detection
- Simplify app.py startup with single validation point at get_app()
- Remove duplicate is_oauth_mode() function (43 lines)
- Fix DeploymentMode mapping (only SELF_HOSTED and SMITHERY_STATELESS exist)
- Add comprehensive unit tests (41 tests covering all modes and edge cases)
- Add enable_multi_user_basic_auth to Settings and BasicAuthMiddleware

Docker Compose:
- Remove conflicting ENABLE_MULTI_USER_BASIC_AUTH from mcp-oauth service
- Add dedicated mcp-multi-user-basic service on port 8003

Test Results:
- 237/237 integration tests PASSED
- All deployment modes verified: single-user BasicAuth, multi-user BasicAuth,
  OAuth single-audience, OAuth token exchange (Keycloak), Smithery stateless

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 20:49:28 +01:00
Chris Coutinho 8682fa4f88 ci: Handle NO_COMMITS_TO_BUMP gracefully in bump scripts
When commitizen finds no eligible commits to bump, it exits with
code 1 and outputs [NO_COMMITS_TO_BUMP]. This was causing the
GitHub Actions workflow to fail even though this is an expected
scenario.

Updated all three bump scripts (bump-mcp.sh, bump-helm.sh,
bump-astrolabe.sh) to:
- Detect the [NO_COMMITS_TO_BUMP] message
- Exit with code 0 (success) instead of code 1
- Output an informational message instead of an error

This allows the bump-version workflow to complete successfully
when no version bumps are needed, matching the workflow's existing
logic that handles empty BUMPED_COMPONENTS.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-20 13:17:50 +01:00
Chris Coutinho 53b84200d4 ci: Gracefully exit on no commit bump 2025-12-20 13:11:11 +01:00
Chris Coutinho f5e5965864 Merge pull request #421 from cbcoutinho/renovate/nextcloud-openapi-extractor-1.x
chore(deps): update dependency nextcloud/openapi-extractor to v1.8.7
2025-12-20 13:01:09 +01:00
Chris Coutinho 989c3d7541 Merge pull request #420 from cbcoutinho/renovate/icewind1991-nextcloud-version-matrix-digest
chore(deps): update icewind1991/nextcloud-version-matrix digest to c2bf575
2025-12-20 13:00:39 +01:00
Chris Coutinho 4bda647271 Merge pull request #422 from cbcoutinho/renovate/peter-evans-create-pull-request-7.x
chore(deps): update peter-evans/create-pull-request action to v7.0.11
2025-12-20 13:00:02 +01:00
Chris Coutinho 32f3380205 Merge pull request #419 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-12-20 12:59:55 +01:00
renovate-bot-cbcoutinho[bot] d29922039b fix(deps): update dependency vue to v3 2025-12-20 11:18:53 +00:00
renovate-bot-cbcoutinho[bot] 12541e57a6 fix(deps): update dependency @nextcloud/vue to v9 2025-12-20 11:18:10 +00:00
renovate-bot-cbcoutinho[bot] 0d6b8a935d chore(deps): update peter-evans/create-pull-request action to v7.0.11 2025-12-20 11:12:56 +00:00
renovate-bot-cbcoutinho[bot] eece9ebadc chore(deps): update dependency nextcloud/openapi-extractor to v1.8.7 2025-12-20 11:12:49 +00:00
renovate-bot-cbcoutinho[bot] c390378278 chore(deps): update icewind1991/nextcloud-version-matrix digest to c2bf575 2025-12-20 11:12:35 +00:00
renovate-bot-cbcoutinho[bot] b99418451c chore(deps): update anthropics/claude-code-action digest to 7145c3e 2025-12-20 11:12:24 +00:00
renovate-bot-cbcoutinho[bot] bd424a1ab7 chore(deps): pin dependencies 2025-12-20 11:12:17 +00:00
github-actions[bot] f8734b3edd bump: version 0.56.1 → 0.56.2 2025-12-20 00:13:59 +00:00
Chris Coutinho 0ea7145df1 Merge pull request #407 from cbcoutinho/renovate/docker-setup-buildx-action-digest
chore(deps): update docker/setup-buildx-action digest to 8d2750c
2025-12-20 01:13:40 +01:00
github-actions[bot] f7a3d2d8f5 bump: version 0.4.3 → 0.4.4 2025-12-20 00:04:37 +00:00
Chris Coutinho 18298177f7 fix(astrolabe): screenshots in info.xml 2025-12-20 01:04:20 +01:00
github-actions[bot] d9fa81082a bump: version 0.4.2 → 0.4.3 2025-12-19 23:58:57 +00:00
Chris Coutinho 651b73545d fix(astrolabe): screenshots in info.xml 2025-12-20 00:58:40 +01:00
github-actions[bot] 46505210cd bump: version 0.4.1 → 0.4.2 2025-12-19 23:52:44 +00:00
github-actions[bot] abf051afdb bump: version 0.56.0 → 0.56.1 2025-12-19 23:52:44 +00:00
Chris Coutinho d4d1a332fb fix(astrolabe): Update screenshots 2025-12-20 00:52:21 +01:00
Chris Coutinho a3ed321e14 fix(ci): skip existing Helm chart releases to prevent duplicate release errors
The chart-releaser workflow was failing when the Helm chart version hadn't
changed but the MCP server version was bumped. Added skip_existing: true to
gracefully handle this scenario.
2025-12-19 22:41:04 +01:00
Chris Coutinho 2bb738ed3f bump: version 0.4.0 → 0.4.1 2025-12-19 22:31:29 +01:00
Chris Coutinho 10c8b62818 bump: version 0.3.2 → 0.4.0 2025-12-19 22:30:46 +01:00
github-actions[bot] 87abadbbfc bump: version 0.55.1 → 0.56.0 2025-12-19 21:29:13 +00:00
Chris Coutinho defc55a5dc feat(ci): add --increment flag to bump scripts for manual version control
Allows forcing specific version bumps (PATCH|MINOR|MAJOR) instead of
relying solely on commitizen's automatic detection based on conventional
commits.

Usage:
  ./scripts/bump-mcp.sh --increment MINOR
  ./scripts/bump-helm.sh --increment PATCH
  ./scripts/bump-astrolabe.sh --increment MAJOR
2025-12-19 22:28:43 +01:00
github-actions[bot] 6a68e45e7c bump: version 0.3.1 → 0.3.2 2025-12-19 21:12:28 +00:00
Chris Coutinho a2fa4b2832 fix(astrolabe): add contents:write permission to appstore workflow
The workflow was failing to create GitHub releases with 'Not Found' error
because it lacked the required permissions. Added contents:write permission
to allow creating releases and uploading artifacts.
2025-12-19 22:12:06 +01:00
github-actions[bot] 9cfadbfc04 bump: version 0.3.0 → 0.3.1 2025-12-19 21:04:50 +00:00
Chris Coutinho 6fed78196e fix(astrolabe): update commitizen pattern to properly update info.xml version
The pattern 'version' was too broad and matched multiple lines:
- <?xml version="1.0"?>
- <version>0.2.1</version>
- min-version="30" max-version="32"

Changed to '<version>' to specifically match only the version tag.

Also fixed version mismatch: info.xml now correctly shows 0.3.0 to match
the version in .cz.toml and package.json.
2025-12-19 22:04:26 +01:00
github-actions[bot] db430dd2c9 bump: version 0.2.0 → 0.3.0 2025-12-19 20:55:59 +00:00
Chris Coutinho 3618aed39e fix(astrolabe): prevent workflow failure when only helm/astrolabe commits exist
When filtering commits with grep -v, if all commits are filtered out,
grep returns exit code 1 which causes the pipeline to fail with set -e.

Wrap grep commands in { ... || true; } to ensure they don't fail the
pipeline when they filter out all results.

This fixes the workflow failure when a fix(astrolabe): commit is pushed
without any MCP server changes.
2025-12-19 21:55:36 +01:00
Chris Coutinho 4c083c7314 fix(astrolabe): info.xml 2025-12-19 21:48:27 +01:00
github-actions[bot] 3202640cf7 bump: version 0.55.0 → 0.55.1 2025-12-19 20:45:55 +00:00
Chris Coutinho c9bbe71869 fix(ci): push all tags explicitly in bump workflow
The --follow-tags flag only pushes annotated tags by default.
Commitizen creates lightweight tags, so we need to explicitly push
all tags with --tags to ensure version tags are pushed to trigger
release workflows.
2025-12-19 21:45:06 +01:00
github-actions[bot] 00edb273cd bump: version 0.54.0 → 0.55.0 2025-12-19 20:35:20 +00:00
Chris Coutinho 608b3282dd fix(ci): make MCP server default bump target for all non-scoped commits
BREAKING CHANGE: MCP server now bumps for ANY conventional commit except
those explicitly scoped to helm or astrolabe.

Previous behavior:
- MCP bumped only for unscoped or scope=mcp commits
- fix(ci): commits were ignored → no version bump

New behavior:
- MCP bumps for ALL commits except scope=helm or scope=astrolabe
- fix(ci): commits now trigger MCP version bump ✓
- feat(api): commits now trigger MCP version bump ✓
- Any custom scope triggers MCP version bump ✓

This treats the MCP server as the default/primary component in the
monorepo, with Helm chart and Astrolabe as opt-in specialized components.

Changes:
1. Updated bump-version.yml workflow logic to exclude helm/astrolabe
   instead of only including mcp/unscoped
2. Updated pyproject.toml commitizen patterns to use negative lookahead:
   (?!\((?:helm|astrolabe)\))
3. Fixed docker-build-publish.yml to only trigger on v* tags (MCP only)
4. Fixed appstore-build-publish.yml action version (v1.0.4)
5. Updated test script to use grep -P for PCRE support
6. Added test cases for ci, api, and custom scopes

All 19 scope filtering tests now pass.
2025-12-19 21:34:49 +01:00
Chris Coutinho 2888bd5693 fix(ci): restrict docker build to MCP server tags only
Docker images should only be built for MCP server releases (v* tags),
not for Helm chart (nextcloud-mcp-server-*) or Astrolabe (astrolabe-v*)
releases.

Changed trigger from all tags to v* pattern only.
2025-12-19 20:48:55 +01:00
Chris Coutinho 90d95da48d fix(ci): correct appstore-push-action version to v1.0.4
The latest available version is v1.0.4, not v1.0.6. This was causing
the Astrolabe app store deployment workflow to fail.
2025-12-19 20:48:28 +01:00
Chris Coutinho 31fb52761e bump: version 0.53.0 → 0.54.0 2025-12-19 20:46:11 +01:00
Chris Coutinho f7e651d0bc bump: version 0.1.0 → 0.2.0 2025-12-19 20:45:59 +01:00
Chris Coutinho ff41fb37fd feat(ci): implement monorepo-aware version bumping workflow
Replace commitizen-action with custom workflow that detects which
components have changes based on commit scopes and bumps them
independently.

The workflow:
1. Checks for commits with scope patterns since last tag for each component:
   - MCP server: scope=mcp or unscoped, tags=v*
   - Helm chart: scope=helm, tags=nextcloud-mcp-server-*
   - Astrolabe: scope=astrolabe, tags=astrolabe-v*

2. Runs appropriate bump script for components with changes:
   - ./scripts/bump-mcp.sh
   - ./scripts/bump-helm.sh
   - ./scripts/bump-astrolabe.sh

3. Pushes all created tags at once

4. Provides GitHub Actions summary showing which components were bumped

This ensures each component versions independently based on its
relevant commits, preventing the issue where all components bump
together or some components are missed.

Fixes the issue where PR #418 only bumped MCP server, leaving Helm
chart and Astrolabe at their previous versions despite having changes.
2025-12-19 20:45:47 +01:00
github-actions[bot] 776c8ad3f7 bump: version 0.53.0 → 0.54.0 2025-12-19 19:34:13 +00:00
Chris Coutinho db97bf8654 Merge pull request #418 from cbcoutinho/feature/appstore-deployment
feat: add App Store deployment and commitizen monorepo support
2025-12-19 20:33:40 +01:00
Chris Coutinho e2e0ffce44 fix(ci): improve versioning and error handling
Addresses remaining high-priority code review feedback:

VERSIONING SCHEME FIXES:
- Helm chart: Changed from pep440 to semver (correct for Helm)
- Astrolabe: Changed from pep440 to semver (correct for Nextcloud apps)
- MCP server: Remains pep440 (correct for Python packages)

Helm charts must use semantic versioning per Helm specification.
Nextcloud apps use semantic versioning in info.xml and package.json.

ENHANCED ERROR HANDLING IN BUMP SCRIPTS:
All three bump scripts now include:
- Comprehensive validation checks
  * Tool availability (uv)
  * Directory structure (must run from repo root)
  * Required files exist (Chart.yaml, info.xml, package.json)
- Better error messages
  * Stderr output for errors
  * Captured commitizen output on failure
  * Common failure causes listed
- Success confirmation
  * Clear indication of what was updated
  * Next steps guidance (git push --follow-tags)
- Robust shell options (set -euo pipefail)

Scripts now provide helpful guidance when:
- No conventional commits found
- No commits with required scope
- Git working directory not clean
- Required dependencies missing
2025-12-19 19:38:24 +01:00
Chris Coutinho 2f3a3e0be4 fix(ci): address critical workflow and validation issues
Addresses code review feedback on PR #418:

CRITICAL FIXES:
1. Workflow trigger: Changed from release:published to push:tags
   - Enables "tag and publish in one step" workflow as intended
   - Automatically creates GitHub release on tag push
   - Removed redundant if condition (filtering now via trigger)
   - Added prerelease detection based on tag (-alpha, -beta, -rc)

2. Server path: Explicitly pass server_dir to make command
   - Fixes path mismatch between local (../../server) and CI
   - Uses absolute path: server_dir=${{ github.workspace }}/server
   - Prevents signing failures in GitHub Actions

3. Regex validation: Added test script for commitizen patterns
   - Validates scope filtering works correctly
   - Tests all three components: mcp, helm, astrolabe
   - Tests unscoped commits (default to mcp)
   - Tests breaking changes and invalid commits
   - Location: scripts/test-commitizen-scopes.sh

WORKFLOW IMPROVEMENTS:
- Release creation now automatic on tag push
- Better step naming for clarity
- Consistent prerelease handling across GitHub and App Store
- Explicit server_dir prevents reliance on fragile relative paths

All 16 test cases pass for scope filtering patterns.
2025-12-19 19:34:21 +01:00
Chris Coutinho c5f7221fb2 fix(astrolabe): address code review feedback
CRITICAL FIXES:
- Fix tag parsing in workflow to strip "astrolabe-v" instead of "v"
  For tag astrolabe-v0.1.0, now correctly extracts "0.1.0"
- Add workflow filtering to only run on astrolabe-v* tags
  Prevents wasting CI resources on MCP/Helm releases

RECOMMENDED IMPROVEMENTS:
- Make Nextcloud server path configurable in Makefile
  Can now override: make appstore server_dir=/path/to/server
- Add dependency validation to Makefile
  Checks for composer, npm, php before building
- Add signing prerequisite validation
  Verifies server/occ, private key, and certificate exist
- Add dependency checks to all bump scripts
  Validates uv is installed before running cz bump

These changes improve local build experience and prevent common
errors with clear error messages and installation guidance.
2025-12-19 18:34:14 +01:00
Chris Coutinho 4a42b947bc feat(astrolabe): add Nextcloud App Store deployment automation
Add complete CI/CD pipeline for automated Astrolabe app releases:
- GitHub Actions workflow for build, sign, and publish
- Makefile for app store package creation
- Version synchronization between info.xml and package.json
- CHANGELOG.md with v0.1.0 release notes

feat: configure commitizen monorepo with independent versioning

Enable independent versioning for three components using scope-based commits:
- MCP Server (feat(mcp) or unscoped): v* tags, updates pyproject.toml + Chart.yaml:appVersion
- Helm Chart (feat(helm)): nextcloud-mcp-server-* tags, updates Chart.yaml:version
- Astrolabe App (feat(astrolabe)): astrolabe-v* tags, updates info.xml + package.json

Changes:
- Add .cz.toml configs for Astrolabe and Helm chart
- Update root pyproject.toml with scope filtering and tag ignores
- Create bump helper scripts (bump-mcp.sh, bump-helm.sh, bump-astrolabe.sh)
- Add CONTRIBUTING.md with version management documentation
- Create component-specific changelogs
- Configure appVersion/version separation for Helm chart

This allows each component to release independently while maintaining
proper version tracking and changelog generation.
2025-12-19 18:06:39 +01:00
github-actions[bot] 46b260641f bump: version 0.52.1 → 0.53.0 2025-12-19 13:23:12 +00:00
Chris Coutinho 60d80970a4 Merge pull request #401 from cbcoutinho/feature/nc-app-ui
feat(astrolabe): Nextcloud app UI with PDF viewer, webhooks, and OAuth refresh
2025-12-19 14:22:42 +01:00
Chris Coutinho daabd90359 fix(security): address critical security issues from PR #401 code review
Implemented 6 critical security fixes identified during PR #401 review:

1. Token Rotation Race Condition (Issue 1)
   - Added in-progress marker pattern to prevent concurrent refresh
   - Prevents token invalidation when multiple requests refresh simultaneously
   - File: token_broker.py:324, 343-390

2. Hardcoded Localhost URL (Issue 2)
   - Added getNextcloudBaseUrl() with fallback chain
   - Supports overwrite.cli.url, trusted_domains, and localhost fallback
   - File: IdpTokenRefresher.php:38-61, 116

3. Error Information Leakage (Issue 3)
   - Replaced 13 instances of str(e) with sanitized errors
   - Prevents exposure of stack traces, paths, and tokens
   - File: management.py:368, 444, 492, 510, 546, 571, 625, 643, 695, 750, 919, 956, 1121

4. Input Validation Gaps (Issue 4)
   - Added validation helpers: _parse_int_param, _parse_float_param, _validate_query_string
   - Applied bounds checking to get_chunk_context and unified_search
   - File: management.py:119-164, 807-835, 1197-1212

5. PHP Refresh Token Validation (Issue 5)
   - Added explicit refresh_token presence check
   - Prevents silent token rotation failures
   - File: IdpTokenRefresher.php:122-132

6. Cookie Security Configuration (Issue 6)
   - Added _should_use_secure_cookies() with auto-detection
   - Supports explicit COOKIE_SECURE env var or auto-detect from NEXTCLOUD_HOST
   - Files: browser_oauth_routes.py:27-44, 470; env.sample:54-57

Testing:
- Unit tests: 195 passed
- Integration tests: 102 passed, 4 skipped
- OAuth tests: 9 passed
- All linting and type checks passed

Follow-up work tracked in issues #408-#417

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-19 13:57:33 +01:00
renovate-bot-cbcoutinho[bot] cb7f9cec2d chore(deps): update docker/setup-buildx-action digest to 8d2750c 2025-12-19 11:10:55 +00:00
Chris Coutinho fe54733a39 fix(oauth): enable PKCE for all clients and add token_broker to oauth_context
This commit fixes two OAuth issues in the Astrolabe app:

1. **Always use PKCE (RFC 9207)**:
   - PKCE is now used for all OAuth flows (public and confidential clients)
   - Previous code only used PKCE for public clients, causing failures
   - Confidential clients now use both PKCE + client_secret (defense in depth)
   - Nextcloud OIDC provider requires PKCE, so token exchange was failing

2. **Add token_broker to oauth_context**:
   - Token broker is now stored in oauth_context for management API access
   - Fixes "Token broker not configured" error when revoking access
   - Revoke endpoint needs token_broker to delete refresh tokens and invalidate cache

Changes:
- OAuthController.php: Always generate PKCE verifier/challenge for all clients
- OAuthController.php: Always include code_verifier in token exchange
- app.py: Store token_broker in oauth_context after creation

Fixes:
- Astrolabe OAuth flow now works with Nextcloud OIDC
- Revoke/disconnect functionality now works in Astrolabe settings

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 01:55:04 +01:00
Chris Coutinho 8d6eff2792 fix(astrolabe): revert invalid files_pdfviewer URL for file links
The files_pdfviewer app route is internal to Nextcloud and not a valid
external URL. Reverted to using the standard Files app viewer URL for
all file types.

- Removed PDF-specific handling that used /apps/files_pdfviewer/
- All files now link to /apps/files/files/{id} (standard Files viewer)
- Fixes broken links in chunk modal titles and search results

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 01:54:39 +01:00
Chris Coutinho e4f3beee01 fix: resolve type checking warnings for CI
- Add type casts for Starlette app state access
- Add assertions for cipher, card, board, stack after initialization
- Add None checks for XML element text attributes
- Handle __package__ being None in tracing setup
- Fix TokenBrokerService initialization to use storage credentials

Resolves 42 type warnings from ty-check, enabling CI linting to pass.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:44:58 +01:00
Chris Coutinho 54b69f0d68 fix: move Alembic to package submodule for Docker compatibility
- Move alembic/ directory to nextcloud_mcp_server/alembic/ subpackage
- Update migrations.py to use package location instead of alembic.ini
- Update env.py to set script_location dynamically
- Update alembic.ini for development CLI usage
- Fix Dockerfile typo: .vnev -> .venv

This fixes FileNotFoundError when running in Docker with non-editable
install. The alembic package is now installed with the main package,
making it work in both development and production environments.

Resolves: Docker startup error 'alembic.ini not found at
/app/.venv/lib/python3.12/site-packages/alembic.ini'

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:42:59 +01:00
Chris Coutinho c4b3df04a0 docs(astrolabe): rewrite README for release with pitch integration
Rewrote Astrolabe README to be user-friendly and release-ready by
incorporating pitch.md content and moving technical details to linked
documentation.

Key changes:
- Incorporated compelling pitch narrative as opening
- Restructured around "What You Can Do" rather than architecture
- Added clear use cases for individuals, teams, and developers
- Simplified installation to 3 steps
- Moved OAuth flow and architecture details to ADR links
- Added emoji sections for visual appeal
- Focused on benefits over implementation

Sections:
- What You Can Do (search, visualization, AI agents)
- Installation (app store + manual)
- Quick Start (3-step setup)
- Features (personal, admin, unified search)
- Use Cases (research, collaboration, RAG workflows)
- Requirements (Nextcloud 30+, MCP server, OAuth)
- Documentation (links to installation, configuration, ADRs)
- Troubleshooting (quick fixes with links to detailed guides)

This README is now suitable for Nextcloud App Store submission.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:09 +01:00
Chris Coutinho d4c0da85da docs: update running guide to prioritize Docker usage
Updated docs/running.md to use Docker container examples instead of
direct Python commands. This aligns with the CLI change to require
explicit 'run' subcommand while maintaining backward compatibility
for Docker users (ENTRYPOINT includes 'run').

Key changes:
- Quick Start: Use Docker commands instead of uv run
- Running Locally → Running with Docker: All examples use Docker
- Development Mode: Added CLI subcommands documentation (run/db)
- Database Migrations: Documented Alembic integration for developers
- Server Options: Docker port mapping instead of --host/--port flags
- Process Management: Simplified to Docker Compose only (removed systemd)
- Performance Tuning: Production Docker Compose with resource limits
- Troubleshooting: Docker logs and debug commands

Updated Dockerfile ENTRYPOINT:
- Changed from: ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
- Changed to: ["/app/.venv/bin/nextcloud-mcp-server", "run", "--host", "0.0.0.0"]

No breaking changes for Docker/Helm users - container interface unchanged.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:09 +01:00
Chris Coutinho 3fa376905c feat: add Alembic database migration system
Implements Alembic for managing token storage database schema versions.
Migrations run automatically on startup with full backward compatibility.

**Changes:**
- Add Alembic dependency (1.14.0+) and SQLAlchemy (auto-installed)
- Create migration infrastructure in alembic/ directory
- Add initial migration (001) capturing current schema
- Modify RefreshTokenStorage.initialize() to run migrations via anyio
- Add CLI commands: db upgrade, current, history, downgrade, migrate
- Add comprehensive migration documentation

**Backward Compatibility:**
- Pre-Alembic databases automatically stamped with revision 001
- No schema changes for existing databases
- Automatic upgrade on first startup after update

**Migration Strategy:**
Three scenarios handled:
1. New database → Run migrations from scratch
2. Pre-Alembic database → Stamp with 001 (no changes)
3. Alembic-managed → Upgrade to latest

**Architecture:**
- Uses anyio.to_thread.run_sync() for structured concurrency
- Alembic env.py runs with anyio.run() in worker thread
- SQLite-friendly migration patterns documented
- No ThreadPoolExecutor needed (anyio handles it)

**CLI Usage:**
```bash
nextcloud-mcp-server db upgrade    # Upgrade to latest
nextcloud-mcp-server db current    # Show version
nextcloud-mcp-server db history    # View changelog
nextcloud-mcp-server db downgrade  # Rollback (with confirmation)
nextcloud-mcp-server db migrate "description"  # Create migration
```

**Testing:**
- All 13 webhook storage tests pass
- New/pre-Alembic database scenarios validated
- anyio integration tested

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:09 +01:00
Chris Coutinho a4a34e46a8 feat: make chunk modal title clickable link to documents
- Add clickable link to modal title with OpenInNew icon
- Store currentResult to enable document navigation
- Fix deck_card URLs to use metadata.board_id
- Fix news_item URLs to use external article URL from metadata.url
- Add hover styling for title link and icon

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:09 +01:00
Chris Coutinho d235dfa023 chore: Rename Astroglobe -> Astrolabe 2025-12-18 00:02:08 +01:00
Chris Coutinho 24898439cb fix: update unified search results to match chunk viz display
Update the unified search provider to show only chunk/page metadata
in search results, consistent with the chunk visualization result list.
Also fix news item URLs to link directly to the specific item.

Changes to SemanticSearchProvider:

1. Result display improvements:
   - Remove excerpt text from search result subline
   - Show only chunk/page metadata (e.g., "Chunk 2/5 · Page 3/10")
   - Consistent with chunk visualization UI in App.vue

2. News item URL fix:
   - Change from generic news index to specific item URL
   - Format: /apps/news/item/{id}
   - Allows direct navigation to the news article

3. Code cleanup:
   - Remove unused $excerpt variable
   - Remove unused truncateExcerpt() method
   - Simplify transformResult() logic

Benefits:
- Cleaner, more scannable search results
- Consistent UX between unified search and app UI
- Functional links to news items instead of generic news page
- Reduced code complexity
2025-12-18 00:02:08 +01:00
Chris Coutinho 6da98b4e7b feat: add native Plotly hover styling for clickable points
Replace expensive Plotly.restyle() hover handlers with native hoverlabel
styling to indicate clickable points without performance degradation.

Implementation:
- Add hoverlabel configuration to document trace with distinct styling
- Bright blue background (#0082c9) to make hover state obvious
- Larger font size (15px) for better visibility
- White text for contrast against blue background
- Handled natively by Plotly - no JavaScript event handlers needed

Benefits:
- Zero performance impact - no chart re-renders on hover
- Smooth, responsive hover feedback
- Clear visual indication that points are clickable
- Consistent with existing hover tooltip pattern

Removed:
- Expensive handlePlotHover() and handlePlotUnhover() methods
- Plotly.restyle() calls that caused severe lag and freezing
- hover/unhover event listener registrations

The hover tooltip now uses the styled hoverlabel to stand out visually,
providing clear feedback that points are interactive without any
performance cost.
2025-12-18 00:02:08 +01:00
Chris Coutinho fba4b9b785 feat: add click interactivity to Plotly 3D scatter chart
Enable users to click on points in the vector space visualization to
open the chunk viewer modal, providing a more direct interaction
method alongside the existing "Show Chunk" button.

Implementation details:
- Register plotly_click event handler in renderPlot() after chart creation
- Add handlePlotClick() method to process click events
- Use point index mapping to access full result object from this.results
- Add loading guard in viewChunk() to prevent concurrent chunk loading
- Add cursor styling: pointer for result points, default for query point
- Add beforeDestroy() lifecycle hook to cleanup event handlers

Features:
- Both interaction methods work: click chart points OR "Show Chunk" button
- Only result points (trace 0) are clickable, query point (trace 1) ignored
- Pointer cursor on hover indicates clickable points
- Loading state prevents rapid clicks from causing issues
- Memory leak prevention through proper event handler cleanup

Technical approach:
- Uses index mapping (not data duplication) for efficiency
- Results and coordinates arrays have guaranteed 1:1 mapping from API
- Event handler re-registered on each chart re-render
- CSS-based cursor styling (more performant than JS hover handlers)

Testing:
- ESLint validation passed
- Follows Vue 2.7 component property order conventions
- Compatible with existing chunk viewer modal
2025-12-18 00:02:08 +01:00
Chris Coutinho b246a03ac4 feat: improve chunk viewer with fixed navigation and markdown rendering
This commit implements three UI improvements for the chunk viewer:

1. Fixed modal footer with navigation controls
   - Moved PDF navigation buttons to a fixed footer
   - Footer remains visible while scrolling content
   - Three-section layout: fixed header, scrollable body, fixed footer

2. Removed duplicate navigation controls
   - Removed previous/next buttons from PDFViewer component
   - Controls now only in App.vue modal footer
   - Cleaned up unused imports and CSS

3. Markdown rendering for chunk content
   - Created MarkdownViewer component using markdown-it
   - Renders markdown content aligned with Nextcloud design system
   - Removed problematic markdown-it-task-checkbox dependency
   - Combines before/chunk/after context with visual separators

4. Cleaned up search results display
   - Removed excerpt snippets from results list
   - Kept only chunk/page metadata for cleaner UI

The modal structure now has:
- Fixed header (title + close button)
- Scrollable body (PDF canvas or markdown content)
- Fixed footer (page navigation - always visible)

Fixes markdown rendering "require is not defined" error by using
only markdown-it without CommonJS plugins.
2025-12-18 00:02:08 +01:00
Chris Coutinho 04c64e97b0 fix(astrolabe): handle OAuth refresh token rotation
Fixes 401 errors after first token refresh when using IdPs that
implement refresh token rotation (Keycloak, modern OAuth providers).

**Root Cause**:
McpTokenStorage::getAccessToken() was discarding the new refresh token
returned by the IdP after successful refresh, always keeping the old one.
This caused:
- First refresh: works (uses original refresh token)
- Second refresh: fails with 401 (old refresh token invalidated by IdP)

**Solution**:
Use new refresh token from IdP response if provided, fall back to old
token for providers that don't rotate refresh tokens.

**Changed**:
- lib/Service/McpTokenStorage.php:184
  From: $token['refresh_token']  // Always old token
  To:   $newTokenData['refresh_token'] ?? $token['refresh_token']

**Verified**:
ApiController already handles rotation correctly using the same pattern.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:02:02 +01:00
Chris Coutinho af9a55cebd feat(astrolabe): enable multi-select for document types and refactor PDF viewer
This commit includes two improvements to the Astroglobe semantic search UI:

1. **Multi-select Document Types** (App.vue):
   - Changed NcCheckboxRadioSwitch binding from v-model to :checked/:update:checked
   - Implemented toggleDocType() method to manually manage selectedDocTypes array
   - Fixes issue where only single document type could be selected at a time
   - Users can now filter search results by multiple doc types simultaneously

2. **PDF Viewer Reactive Rendering** (PDFViewer.vue):
   - Refactored canvas rendering to use Vue reactive watcher pattern
   - Added watcher on 'loading' state that triggers rendering when canvas available
   - Removed imperative renderPage() call from loadPDF() method
   - Inspired by files_pdfviewer's promise/event-based initialization approach
   - Improves alignment with Vue's reactive data flow

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:54 +01:00
Chris Coutinho 44391d3d1d fix: address critical code review issues (4 fixes)
This commit addresses 4 critical issues identified in code review:

1. **Token Rotation Race Condition** (token_broker.py)
   - Added per-user locking mechanism to prevent concurrent refresh token corruption
   - Implemented double-check pattern for cache after acquiring lock
   - Users can now safely refresh concurrently without token desync

2. **Hardcoded OAuth Client ID** (PHP files)
   - Made client ID configurable via `astroglobe_client_id` in system config
   - Updated McpServerClient to provide getClientId() method
   - Injected McpServerClient into IdpTokenRefresher and OAuthController
   - Updated admin settings UI to display client ID configuration status
   - App gracefully handles missing client ID with warnings in admin UI

3. **Missing Cache Invalidation** (management.py:revoke_user_access)
   - Added cache.invalidate() call when revoking user access
   - Ensures both storage AND cache are cleared atomically
   - Prevents stale cached tokens from being used after revocation

4. **Error Message Exposure** (management.py)
   - Created _sanitize_error_for_client() helper function
   - Updated all error handlers to log detailed errors internally
   - Returns generic messages to clients to prevent information leakage
   - Protects against exposing database paths, API URLs, tokens, etc.

All changes are backward compatible and preserve existing functionality.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:54 +01:00
Chris Coutinho 619c62d89a ci: Remove --headed from pyproject.toml 2025-12-18 00:01:54 +01:00
Chris Coutinho dfc81923ba fix: resolve CI linting issues for Astroglobe
Fix all ESLint, Stylelint, PHP CS Fixer, and Psalm workflow errors.

Changes:
- ESLint fixes:
  - Remove unused APP_NAME constant
  - Remove unused TextBoxOutline and TextBoxRemoveOutline components
  - Remove unused container variable in adminSettings.js
  - Auto-fix trailing commas, line breaks, attribute ordering
- PHP CS Fixer:
  - Add trailing commas after last function parameters
  - Convert double quotes to single quotes in log messages
  - Remove unused NoCSRFRequired import
  - Fix arrow function formatting
- Stylelint:
  - Update config to use @nextcloud/stylelint-config
  - Fix extends directive (was using non-existent package)
- Psalm workflow:
  - Fix jq object indexing (.include[0] instead of .[0])
  - Correctly extract OCP version from matrix output

All checks now pass locally.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:54 +01:00
Chris Coutinho 5a6205476a ci: add consolidated GitHub workflow for Astroglobe app
Create single workflow that includes all key checks from Nextcloud app
skeleton instead of copying 14 separate workflow files.

Changes:
- Create astroglobe-ci.yml workflow:
  - Triggers on PRs modifying third_party/astroglobe/
  - Detects frontend vs PHP changes separately
  - Frontend checks: Node.js build, ESLint, Stylelint
  - PHP checks: CS Fixer, Psalm static analysis
  - Uses official Nextcloud actions (version-matrix, read-package-engines)
  - Runs checks only for changed file types
  - Summary job for branch protection rules

Benefits:
- Consolidated workflow easier to maintain than 14 files
- Follows Nextcloud app quality standards
- Catches issues before deployment
- Automatic checks on every PR

Based on Nextcloud app skeleton workflows from:
https://github.com/nextcloud/.github

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:53 +01:00
Chris Coutinho be7f512244 docs: document deployment modes and Nextcloud log querying
Update ADR-018 with comprehensive deployment architecture and add
Nextcloud application log querying patterns to CLAUDE.md.

Changes:
- ADR-018 deployment modes documentation:
  - Mode 1: Basic single-user (development/simple)
  - Mode 2: Basic multi-user pass-through (no OIDC)
  - Mode 3: OAuth multi-user with progressive consent
  - Authentication flows for each mode
  - Communication path diagrams
  - Implementation examples
  - Use cases and limitations
- CLAUDE.md additions:
  - Nextcloud application log querying patterns
  - Common jq filters for debugging
  - Log structure documentation
  - App-specific filtering examples

Benefits:
- Clear guidance on deployment architecture selection
- Documented authentication flows for all scenarios
- Easier debugging with log query patterns
- Complete reference for mode-specific configurations

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:53 +01:00
Chris Coutinho 5eec34c17e feat(auth): implement refresh token rotation for Nextcloud OIDC
Add support for one-time use refresh tokens with automatic rotation
to align with Nextcloud OIDC security model.

Changes:
- TokenBrokerService improvements:
  - Add user_id parameter to refresh methods
  - Detect and store rotated refresh tokens
  - Add offline_access scope to token requests
  - Handle refresh token rotation on every use
- Add management API endpoints:
  - /api/v1/webhooks (GET/POST) - List/create webhooks
  - /api/v1/webhooks/{id} (DELETE) - Delete webhook
  - /api/v1/search (POST) - Unified search
  - /api/v1/chunk-context (GET) - Get chunk context
  - /api/v1/apps (GET) - List installed apps
- Update tests for refresh token rotation
- Add --headed flag to pytest for Playwright debugging

Benefits:
- Aligns with Nextcloud OIDC one-time refresh token model
- Prevents refresh token invalidation after first use
- Enables long-lived background operations
- Provides full webhook lifecycle management

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:53 +01:00
Chris Coutinho 656214b162 chore(docker): update app hooks for confidential OAuth client
Configure Astroglobe as a confidential OAuth client with client_secret
to support token refresh for long-lived sessions.

Changes:
- Update install-astroglobe-app hook to:
  - Create confidential client instead of public
  - Add offline_access scope for refresh tokens
  - Extract and store client_secret in system config
  - Display secret (truncated) for verification
- Update trusted-domains hook (formatting)

Benefits:
- Enables automatic token refresh without re-authentication
- Supports long-lived backend operations
- Better security for server-side OAuth flows

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:53 +01:00
Chris Coutinho 45fc25d02b feat(astrolabe): enhance unified search and add webhook management
Improve unified search results with chunk/page metadata and add
webhook management capabilities to McpServerClient.

Changes:
- SemanticSearchProvider improvements:
  - Display chunk position (e.g., "Chunk 2/5")
  - Display page numbers for PDFs (e.g., "Page 3/10")
  - Fix file links to open in Files app correctly
  - Fix deck card links to use proper URL format
  - Show metadata in subline before excerpt
  - Use proper icons and thumbnails for each doc type
- McpServerClient webhook methods:
  - listWebhooks() - Get all registered webhooks
  - createWebhook() - Register new webhook
  - deleteWebhook() - Remove webhook registration
  - enableWebhook() / disableWebhook() - Toggle webhook status
  - getWebhookLogs() - Retrieve delivery logs

Benefits:
- Better search result context with chunk and page info
- Clickable links that open correct resources
- Full webhook lifecycle management via API

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:47 +01:00
Chris Coutinho 9aec5582db feat(astrolabe): add webhook management UI to admin settings
Add admin interface for configuring real-time webhook sync with
pre-configured presets for common scenarios.

Changes:
- Add webhook presets section to admin settings page
  - Shows available presets filtered by installed apps
  - Enable/disable presets with one click
  - Displays current webhook status
- Add client secret configuration status display
  - Shows whether confidential client is configured
  - Provides setup instructions for optional client secret
- Add adminSettings.js for webhook management
  - Load webhook presets via API
  - Enable/disable webhook presets
  - Handle search settings form submission
- Update vite.config.js to build adminSettings entry point
- Pass clientSecretConfigured flag to template

UI Features:
- Real-time preset status (enabled/disabled)
- One-click enable/disable for webhook bundles
- App-aware filtering (only shows presets for installed apps)
- Clear instructions for requirements and setup

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:42 +01:00
Chris Coutinho 0f7e87a91c feat(astrolabe): add OAuth token refresh and webhook presets
Implement automatic token refresh and pre-configured webhook bundles
to simplify vector sync configuration.

Changes:
- Add IdpTokenRefresher service for automatic OAuth token renewal
  - Works with both Nextcloud OIDC and external IdPs (Keycloak)
  - Uses OIDC discovery for automatic endpoint detection
  - Supports confidential clients with client_secret
- Add WebhookPresets service with pre-configured bundles:
  - Notes sync (file created/written/deleted in Notes folder)
  - Calendar sync (calendar object created/updated/deleted)
  - Tables sync (row added/updated/deleted, Nextcloud 30+)
  - Forms sync (form submitted, Nextcloud 30+)
- Update ApiController to use automatic token refresh
  - Pass refresh callback to McpTokenStorage
  - Add getWebhookPresets endpoint (admin-only)
  - Add configureWebhooks endpoint for bulk setup
- Update OAuthController for webhook management
- Add new API routes for webhook configuration

Benefits:
- Eliminates manual token refresh
- Simplifies webhook setup with one-click presets
- Provides app-aware filtering (only shows presets for installed apps)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:35 +01:00
Chris Coutinho 5acac804a1 refactor(astrolabe): extract PDF viewer to dedicated component
Replace fragile 20-iteration retry loop with proper Vue lifecycle management.

Changes:
- Create PDFViewer.vue component (~200 lines) with:
  - Proper mounted/beforeUnmount lifecycle hooks
  - Loading/error/content states
  - Page navigation controls
  - PDF document cleanup
  - User-friendly error messages
- Simplify App.vue by removing ~120 lines:
  - Remove loadPdf() and renderPdfPage() methods
  - Remove manual DOM polling with $nextTick() loops
  - Replace PDF template with <PDFViewer> component
- Add pdfjs-dist@4.0.379 dependency

Result: Canvas found on first attempt instead of requiring 20 retries.

Follows patterns from Nextcloud's production files_pdfviewer app and
2025 Vue.js + PDF.js best practices.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:30 +01:00
Chris Coutinho 85db90a2df feat(search): add file_path metadata and chunk offsets to search results
Changes:
- Add file_path to metadata in semantic and BM25 hybrid search algorithms
  for PDF viewer integration (search/semantic.py:161-163, search/bm25_hybrid.py:230-232)
- Include chunk_start_offset, chunk_end_offset, page_number, and page_count
  in search results for rich chunk display (api/management.py:981-1004)
- Add point_id field to SearchResult for batch retrieval (models/semantic.py)
- Fix type narrowing for chunk context API parameters (api/management.py:1102-1111)
- Fix None-safety in doc_types discovery (search/algorithms.py:114)

This enables the Astroglobe UI to display PDF pages at the correct
location for matched chunks.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:29 +01:00
Chris Coutinho a026f2eddb chore: update .gitignore for build artifacts and screenshots
Add patterns to ignore:
- Screenshots and test images (*.png, *.jpg, *.jpeg)
- Astroglobe frontend build artifacts (dist/, node_modules/)
- Unrelated third-party apps (third_party/spreed/)
2025-12-18 00:01:29 +01:00
Chris Coutinho 73783b85d5 feat(astrolabe): use proper icons and thumbnails in unified search
Improve search result display to match Nextcloud's native search providers by using mimetype-specific icons and preview thumbnails.

**File Results:**
- Use preview thumbnails for images/PDFs (core.Preview API)
- Use mimetype-specific icon classes (icon-pdf, icon-text, icon-image, etc.)
- Detect folders and use icon-folder appropriately

**Other Document Types:**
- Notes: icon-notes
- Deck Cards: icon-deck
- Calendar: icon-calendar
- News: icon-rss
- Contacts: icon-contacts

**API Changes:**
- Management API now includes mime_type in search results
- SemanticSearchProvider uses IMimeTypeDetector and IPreview services

This makes Astroglobe search results visually consistent with Files, Notes, and other native providers.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:24 +01:00
Chris Coutinho 4cce4f6392 feat(astrolabe): add admin search settings and enhanced UI
Add comprehensive admin controls for the unified search provider and enhance the frontend UI with filtering and visualization improvements.

**Admin Settings:**
- Configure default search algorithm (hybrid, semantic, bm25)
- Set fusion method for hybrid search (rrf, dbsf)
- Adjust minimum score threshold (0-100%)
- Set result limit (1-100 results)

**Frontend Enhancements:**
- Add score-based result filtering with slider control
- Add expandable excerpts for search results
- Improve result visualization and formatting
- Add algorithm badge to show search method used

**API Changes:**
- Add `/api/admin/search-settings` POST endpoint
- Add `searchForUnifiedSearch()` method to McpServerClient
- Load and apply admin settings in SemanticSearchProvider

**Technical Details:**
- Settings stored in app config table
- Defaults: hybrid algorithm, rrf fusion, 0% threshold, 20 results
- SemanticSearchProvider respects admin-configured limits

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:19 +01:00
Chris Coutinho 24e63a967a ci: Update oidc app 2025-12-18 00:01:18 +01:00
Chris Coutinho dbb6ba333a feat(astrolabe): add unified search provider with clickable file links
Integrate semantic search into Nextcloud's unified search UI. File results now use fileId parameter to properly open files instead of just navigating to the Files app.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-18 00:01:14 +01:00
Chris Coutinho 97b48ca3dd feat(astrolabe): add 3D PCA visualization for semantic search
- Add Plotly.js 3D scatter plot showing search results in PCA space
- Create shared visualization.py module to avoid code duplication
- Pass include_pca parameter through API chain to enable coordinates
- Fix OAuth redirects to use /settings/user/astroglobe

The visualization shows document embeddings projected to 3D via PCA,
with the query point highlighted in red. Uses Viridis colorscale
for score visualization, matching the existing vector-viz page.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 00:01:09 +01:00
Chris Coutinho a4106ee20d refactor(astrolabe): reframe UI as semantic search service
Update all user-facing text to focus on Astroglobe as a semantic
search service for Nextcloud users:

- info.xml: New description focusing on finding content by meaning
- Settings sections: Renamed from "MCP Server" to "Astroglobe"
- Personal settings: Reframed as content indexing controls
- Admin settings: Reframed as semantic search administration
- OAuth flow: Explains semantic search benefits to users

Key messaging changes:
- "MCP Server" → "Astroglobe"
- "Grant Background Access" → "Enable Semantic Search"
- "Vector Sync" → "Content Indexing"
- Focus on user benefits: natural language search, finding by meaning

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 00:01:00 +01:00
Chris Coutinho 21817543ad feat(astrolabe): add Nextcloud PHP app for MCP server management
Adds a native Nextcloud app "Astroglobe" that provides:
- Personal settings: OAuth authorization for background MCP access
- Admin settings: Server status and vector sync monitoring
- API endpoints for MCP server communication

The app uses PKCE OAuth flow to obtain tokens for the MCP server,
enabling features like background vector sync per ADR-018.

Includes:
- PHP app structure (controllers, services, settings)
- Vue.js frontend components
- Docker compose mount configuration
- Installation hook for development testing
- ADR-018 documentation

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 00:00:40 +01:00
Chris Coutinho 6babbc99e7 Merge pull request #403 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.18
2025-12-17 19:46:00 +01:00
Chris Coutinho 1f5e9d815b Merge pull request #402 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to d7b6d50
2025-12-17 19:44:51 +01:00
renovate-bot-cbcoutinho[bot] 83caa48cdb chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.18 2025-12-17 11:07:53 +00:00
renovate-bot-cbcoutinho[bot] b51019a7e8 chore(deps): update anthropics/claude-code-action digest to d7b6d50 2025-12-17 11:07:48 +00:00
Chris Coutinho 72d65cd7ae Merge pull request #400 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.3
chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 53231a9
2025-12-15 12:53:17 +01:00
renovate-bot-cbcoutinho[bot] 76251e935e chore(deps): update docker.io/library/nextcloud:32.0.3 docker digest to 53231a9 2025-12-15 11:06:31 +00:00
Chris Coutinho a58a14111b feat(vector-sync): enable background sync in OAuth mode
Add multi-user background vector synchronization when running in OAuth
mode with ENABLE_OFFLINE_ACCESS=true. Key changes:

Architecture (oauth_sync.py):
- User Manager task polls RefreshTokenStorage for provisioned users
- Per-user scanner tasks fetch documents using OAuth tokens
- Shared processor pool indexes documents from all users

Token Broker improvements:
- Accept client_id/client_secret instead of encryption_key
- Remove redundant token audience pre-validation (Nextcloud validates)
- Add _rewrite_token_endpoint for Docker internal URL routing
- Remove double-decryption (storage handles encryption internally)

Browser OAuth flow fixes:
- Add 'resource' parameter to request Nextcloud-scoped tokens
- Store and retrieve next_url for proper redirect after consent
- Rewrite token endpoint URLs for internal Docker access

Configuration:
- Add vector_sync_user_poll_interval setting (default: 60s)

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 20:00:41 +01:00
Chris Coutinho 49230c3a44 Merge pull request #398 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.6
2025-12-14 15:26:44 +01:00
Chris Coutinho 262d2b2133 Merge pull request #397 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 052b75a
2025-12-14 15:26:31 +01:00
Chris Coutinho ad2ff2ccc4 Merge pull request #399 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.36.0
2025-12-14 15:22:50 +01:00
renovate-bot-cbcoutinho[bot] dff7a58736 chore(deps): update helm release ollama to v1.36.0 2025-12-14 11:07:14 +00:00
renovate-bot-cbcoutinho[bot] 44c9bd645e chore(deps): update astral-sh/setup-uv action to v7.1.6 2025-12-14 11:06:57 +00:00
renovate-bot-cbcoutinho[bot] 4741d60e4c chore(deps): update docker.io/library/nginx:alpine docker digest to 052b75a 2025-12-14 11:06:52 +00:00
github-actions[bot] 1a079a41e7 bump: version 0.52.0 → 0.52.1 2025-12-13 23:24:55 +00:00
Chris Coutinho ebbd3bcc61 Merge pull request #396 from cbcoutinho/feat/deck-vector-search
perf(deck): Optimize card lookup with O(1) metadata-based retrieval
2025-12-14 00:24:25 +01:00
Chris Coutinho 54fdc8addc Merge remote-tracking branch 'origin/master' into feat/deck-vector-search 2025-12-14 00:23:16 +01:00
Chris Coutinho e0320e761c perf(deck): optimize card lookup by storing board_id/stack_id in metadata
Addresses reviewer feedback on PR #395 about O(n²) performance issue.

Changes:
- scanner.py: Add metadata field to DocumentTask with board_id/stack_id
- scanner.py: Populate metadata during deck card scanning (both initial and incremental sync)
- processor.py: Use metadata for O(1) card lookup via get_card() API when available
- processor.py: Fallback to iteration for legacy data without metadata
- context.py: Add _get_deck_metadata_from_qdrant() helper to retrieve metadata from Qdrant
- context.py: Use metadata for fast path lookup in chunk context expansion
- context.py: Add user_id parameter to _fetch_document_text() for metadata retrieval

Performance Impact:
- Before: O(boards × stacks × cards) iteration for each card lookup
- After: O(1) direct API call using stored board_id/stack_id
- Graceful degradation: Falls back to iteration for legacy data

Testing:
- All existing integration tests pass (test_deck_vector_search.py)
- Type checking passes with no new errors

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-14 00:23:12 +01:00
github-actions[bot] 2b7c308188 bump: version 0.51.0 → 0.52.0 2025-12-13 22:56:03 +00:00
Chris Coutinho 40ac52654f Merge pull request #395 from cbcoutinho/feat/deck-vector-search
feat(vector): Add Deck card vector search with visualization support
2025-12-13 23:55:31 +01:00
Chris Coutinho 034e405824 build: Add qdrant-client until upstream issue is merged 2025-12-13 23:51:43 +01:00
Chris Coutinho 20404cf3f2 feat(vector): add Deck card vector search with visualization support
Adds comprehensive vector search support for Nextcloud Deck cards,
including semantic search indexing, chunk preview in the vector viz UI,
and proper deep linking to cards.

**Vector Search Indexing**
- Add deck_card scanning in scanner.py (scan_deck_cards function)
- Index cards from non-archived, non-deleted boards
- Store metadata: board_id, board_title, stack_id, stack_title, card_type, duedate, owner
- Content structure: title + "\n\n" + description (matches indexing format)
- Incremental sync based on lastModified timestamp
- Deletion tracking with grace period

**Vector Visualization Support**
- Add deck_card handler in context.py for chunk preview expansion
- Include board_id in search result metadata (bm25_hybrid.py, semantic.py)
- Expose metadata in viz_routes.py JSON responses
- Update vector-viz.js to construct proper Deck URLs: /apps/deck/board/{board_id}/card/{card_id}
- Update vector_viz.html filter label from "Deck" to "Deck Cards"

**Bug Fixes**
- Skip soft-deleted boards (deletedAt > 0) to prevent 403 Forbidden errors
- Applies to scanner, processor, and context expansion code paths
- Deck API returns deleted boards but rejects stack access with 403

**Testing**
- Add integration tests in test_deck_vector_search.py:
  - test_deck_card_semantic_search: Filtered search with doc_type="deck_card"
  - test_deck_card_appears_in_cross_app_search: Cross-app search includes deck cards
  - test_deck_card_chunk_context: Chunk context fetching for viz preview

**Documentation**
- Update README.md: Add Deck cards to semantic search feature list
- Update semantic-search-architecture.md: Document deck_card support
- Update nc_semantic_search tool documentation

**Type Safety**
- Fix type narrowing for page_boundaries (could be None) using cast()
- Fix scanner.py payload None check for type safety

Resolves vector search for Deck cards across indexing, search, and visualization.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 23:51:18 +01:00
github-actions[bot] 264bb5475c bump: version 0.50.2 → 0.51.0 2025-12-13 21:24:19 +00:00
Chris Coutinho 6e3f9f6e79 Merge pull request #394 from cbcoutinho/news-link
feat(vector-viz): add news_item support for links and chunk expansion
2025-12-13 22:23:48 +01:00
Chris Coutinho 9d0a993c2a feat(vector-viz): add news_item support for links and chunk expansion
Add support for news_item document type in the vector visualization page:

- Add "News" checkbox to document type filter options
- Add URL handler to link news items to /apps/news/item/{id}
- Add content fetching for news items in chunk context expansion

This enables users to search and view news articles in the vector
visualization, with clickable links back to Nextcloud News and the
ability to expand chunks to see full article context.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 21:34:47 +01:00
github-actions[bot] cd3e60ba4f bump: version 0.50.1 → 0.50.2 2025-12-13 14:53:42 +00:00
Chris Coutinho 360299f5f6 Merge pull request #393 from cbcoutinho/fix/news-api-get-item-405-error
fix(news): revert get_item() to use get_items() + filter
2025-12-13 15:53:11 +01:00
Chris Coutinho d61e33113c fix(news): revert get_item() to use get_items() + filter
Reverts the "perf(news): use direct API endpoint for get_item()" change
from commit 92c4bf3 which incorrectly assumed GET /items/{itemId} exists.

The News API (v1-2, v1-3, v2) does not provide a direct endpoint to
retrieve individual items. The only /items/{itemId} routes are POST
operations for marking items read/unread/starred.

Changes:
- Restore original get_item() implementation that fetches all items
  and filters in Python
- Update exception from HTTPStatusError to ValueError
- Restore documentation explaining API limitation
- Update unit tests to mock get_items() instead of _make_request()
- Add test for ValueError when item not found

Fixes vector processor 405 errors when indexing news items.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-13 15:47:27 +01:00
Chris Coutinho 5faf7cf45f Merge pull request #391 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 fa48eef
2025-12-13 12:56:55 +01:00
renovate-bot-cbcoutinho[bot] cd922fa750 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to fa48eef 2025-12-13 11:07:41 +00:00
github-actions[bot] a4d4c386f7 bump: version 0.50.0 → 0.50.1 2025-12-12 17:00:34 +00:00
Chris Coutinho c8da826ef7 Merge pull request #382 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.23,<1.24
2025-12-12 18:00:04 +01:00
Chris Coutinho 5166c2c4d7 test: Add verification test for DNS rebinding protection fix
This test verifies that the MCP 1.23.x DNS rebinding protection fix works
correctly by sending requests with various Host headers that would be
rejected if the protection were enabled.

Test cases:
- Kubernetes service DNS (nextcloud-mcp-server.default.svc.cluster.local:8000)
- Custom domain (mcp.example.com:8000)
- Proxied hostname (proxy.internal:8000)
- Default localhost (localhost:8000)
- Malicious hostname (evil.attacker.com:8000)

Without the fix (enable_dns_rebinding_protection=False), these would fail with:
- 421 Misdirected Request (Host header not in allowed list)
- 403 Forbidden (Origin header not in allowed list)

With the fix, all requests succeed with 200 OK (SSE format).

Test results: All 2 tests passed
- test_accepts_various_host_headers: PASSED
- test_dns_rebinding_protection_is_disabled: PASSED
2025-12-12 17:56:16 +01:00
Chris Coutinho ec70e70a5d fix: Disable DNS rebinding protection for containerized deployments
MCP Python SDK 1.23.0 introduced automatic DNS rebinding protection that
auto-enables when host="127.0.0.1" (the default). This breaks containerized
deployments (Kubernetes, Docker) because the protection rejects requests
with Host headers like "nextcloud-mcp-server.default.svc.cluster.local:8000".

Root cause:
- FastMCP defaults to host="127.0.0.1"
- SDK auto-enables DNS rebinding protection with allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]
- K8s/Docker requests use service DNS names or proxied hostnames
- Protection middleware rejects these requests (421 Misdirected Request)

Solution:
- Explicitly pass transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
- Applied to all three FastMCP initializations (OAuth, Smithery, BasicAuth)
- DNS rebinding attacks mitigated by OAuth authentication and network isolation

This fixes issue #373 and enables MCP 1.23.x upgrade in PR #382.

For detailed analysis, see docs/MCP-1.23-DNS-REBINDING-FIX.md
2025-12-12 17:30:22 +01:00
Chris Coutinho 4a79b37714 Merge pull request #389 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.3
2025-12-12 12:23:44 +01:00
renovate-bot-cbcoutinho[bot] 76ae1c3603 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.3 2025-12-12 11:09:06 +00:00
github-actions[bot] a60b88b80e bump: version 0.49.2 → 0.50.0 2025-12-11 12:58:08 +00:00
Chris Coutinho e31b4433a1 Merge pull request #387 from cbcoutinho/feat/add-mcp-tool-annotations
feat: add MCP tool annotations for enhanced UX
2025-12-11 13:57:35 +01:00
Chris Coutinho 19183ad14a fix: address PR review feedback
Address all reviewer comments from PR #387:

1.  Add unit tests for annotations (tests/server/test_annotations.py)
   - 10 comprehensive test functions validating all annotation patterns
   - Tests for titles, read-only, destructive, idempotent operations
   - Validates specific ADR-017 decisions (webdav write, semantic search)
   - Cross-category consistency checks

2.  Fix nc_webdav_write_file idempotency classification
   - Changed from idempotentHint=False to idempotentHint=True
   - Rationale: Uses HTTP PUT without version control
   - Writing same content to same path = same end state (idempotent)

3.  Fix semantic search openWorldHint inconsistency
   - Changed from openWorldHint=False to openWorldHint=True
   - Rationale: Consistent with other Nextcloud tools
   - Nextcloud is external to MCP server (indexed data is implementation detail)

4.  Update ADR-017 with resolved decisions
   - Converted Open Questions to Resolved Questions
   - Added detailed rationale for webdav write and semantic search
   - Updated status from Proposed to Implemented
   - Added decision timeline with dates

5.  Add MCP Tool Annotations guidelines to CLAUDE.md
   - Comprehensive section with code examples for all patterns
   - Key principles documented (idempotency, destructive, open world)
   - References ADR-017 for detailed rationale

All OAuth tools verified to have proper annotations (oauth_tools.py lines 686-751).
2025-12-11 13:50:55 +01:00
Chris Coutinho e1412320a7 feat: add MCP tool annotations for enhanced UX
Add ToolAnnotations to all 105+ MCP tools across 13 modules to enable
better client-side UX with human-readable titles and behavioral hints.

Changes:
- Add title and ToolAnnotations to all @mcp.tool() decorators
- Apply correct idempotency classification per ADR-017
- Add destructiveHint for delete operations
- Set openWorldHint=False for semantic search (internal data only)

Modules updated:
- OAuth (4 tools): Authentication and provisioning
- Notes (7 tools): Note management
- WebDAV (11 tools): File operations
- Semantic (3 tools): Semantic search and RAG
- Calendar (16 tools): Events and todos
- Contacts (7 tools): Address book management
- Sharing (5 tools): File/folder sharing
- Tables (6 tools): Structured data
- Deck (25 tools): Kanban board management
- Cookbook (13 tools): Recipe management
- News (8 tools): RSS feed reader

Annotation patterns:
- Read operations: readOnlyHint=True, openWorldHint=True
- Create operations: idempotentHint=False, openWorldHint=True
- Update operations: idempotentHint=False, openWorldHint=True
- Delete operations: destructiveHint=True, idempotentHint=True, openWorldHint=True

See docs/ADR-017-mcp-tool-annotations.md for rationale and implementation details.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 12:45:02 +01:00
Chris Coutinho b9c94dfab0 Merge pull request #385 from cbcoutinho/renovate/docker.io-library-nginx-alpine
chore(deps): update docker.io/library/nginx:alpine docker digest to 289deca
2025-12-10 12:56:34 +01:00
Chris Coutinho 6f43c09bd0 Merge pull request #386 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.17
2025-12-10 12:55:50 +01:00
Chris Coutinho 9e15e95c2b Merge pull request #384 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to f0c8eb2
2025-12-10 12:54:21 +01:00
renovate-bot-cbcoutinho[bot] 1306c4cc9c chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.17 2025-12-10 11:10:37 +00:00
renovate-bot-cbcoutinho[bot] f1247817d3 chore(deps): update docker.io/library/nginx:alpine docker digest to 289deca 2025-12-10 11:10:31 +00:00
renovate-bot-cbcoutinho[bot] fdad5b85c9 chore(deps): update anthropics/claude-code-action digest to f0c8eb2 2025-12-10 11:10:26 +00:00
Chris Coutinho 39ee0b5973 Merge pull request #381 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 590cad7
2025-12-09 18:44:34 +01:00
github-actions[bot] 33675c8ae8 bump: version 0.49.1 → 0.49.2 2025-12-09 17:43:25 +00:00
Chris Coutinho 90d5e9887a Merge pull request #383 from cbcoutinho/fix/bump
fix: Update lockfile
2025-12-09 18:42:53 +01:00
Chris Coutinho c3af591810 fix: Update lockfile 2025-12-09 18:42:03 +01:00
renovate-bot-cbcoutinho[bot] bb8a6200aa fix(deps): update dependency mcp to >=1.23,<1.24 2025-12-09 14:54:22 +00:00
Chris Coutinho 44573366eb build: Update lockfile 2025-12-09 15:49:25 +01:00
github-actions[bot] edb0af2bda bump: version 0.49.0 → 0.49.1 2025-12-09 14:46:43 +00:00
Chris Coutinho 7d5bb54b64 fix: Revert mcp version <1.23 2025-12-09 15:46:00 +01:00
Chris Coutinho a18c63792a Merge pull request #380 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.2
chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 04cc195
2025-12-09 15:36:18 +01:00
renovate-bot-cbcoutinho[bot] 0b58707a49 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 590cad7 2025-12-09 11:07:35 +00:00
renovate-bot-cbcoutinho[bot] 0561b55af5 chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 04cc195 2025-12-09 11:07:29 +00:00
Chris Coutinho d785ed9054 Merge pull request #379 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.5
2025-12-08 15:38:49 +01:00
renovate-bot-cbcoutinho[bot] 88fb8417fd chore(deps): update astral-sh/setup-uv action to v7.1.5 2025-12-08 11:07:22 +00:00
github-actions[bot] f70d743c8b bump: version 0.48.6 → 0.49.0 2025-12-08 06:23:14 +00:00
Chris Coutinho 251b8a10c0 Merge pull request #363 from cbcoutinho/feature/news-app-integration
feat(news): add Nextcloud News app integration
2025-12-08 07:22:42 +01:00
Chris Coutinho 3f06e2ee77 fix: resolve all type checking errors (8 errors fixed)
Fixed 8 type checker errors across the codebase:

- vector/scanner.py: Handle None scroll results with null-safe iteration
- search/{bm25_hybrid,semantic}.py: Add None checks for result.payload
- auth/{unified_verifier,webhook_routes}.py: Assert non-None auth credentials
- client/webdav.py: Add None checks before int() conversions
- providers/openai.py: Assert embedding_model is not None
- search/algorithms.py: Explicitly type doc_types set and cast values
- observability/logging_config.py: Match parent class signature (log_data)

Also fixed test_create_tag_creates_system_tag to match WebDAV implementation
(was testing OCS API endpoint, now tests correct WebDAV endpoint with
Content-Location header).

Type checker: 0 errors (down from 8), 20 warnings (ignored)
Tests: All 192 unit tests passing

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-08 01:09:02 +01:00
Chris Coutinho 7f11c793ef Merge remote-tracking branch 'origin/master' into feature/news-app-integration 2025-12-07 22:36:48 +01:00
Chris Coutinho e28dcbff9a Merge pull request #378 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.16
2025-12-07 13:28:38 +01:00
renovate-bot-cbcoutinho[bot] 89ec0186a4 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.16 2025-12-07 11:06:50 +00:00
Chris Coutinho 6e1efde8c6 Merge pull request #375 from cbcoutinho/renovate/qdrant-qdrant-v1.16.2
chore(deps): update qdrant/qdrant:v1.16.2 docker digest to dab6de3
2025-12-05 20:19:08 +01:00
Chris Coutinho 6aa80d4210 Merge pull request #377 from cbcoutinho/renovate/hoverkraft-tech-compose-action-2.x
chore(deps): update hoverkraft-tech/compose-action action to v2.4.2
2025-12-05 20:18:56 +01:00
Chris Coutinho 4e86006b3f Merge pull request #376 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.2
2025-12-05 20:18:32 +01:00
renovate-bot-cbcoutinho[bot] 679e22a7c2 chore(deps): update hoverkraft-tech/compose-action action to v2.4.2 2025-12-05 11:11:41 +00:00
renovate-bot-cbcoutinho[bot] 4d3228a4a8 chore(deps): update helm release qdrant to v1.16.2 2025-12-05 11:11:34 +00:00
renovate-bot-cbcoutinho[bot] 0aa307f0b6 chore(deps): update qdrant/qdrant:v1.16.2 docker digest to dab6de3 2025-12-05 11:11:18 +00:00
Chris Coutinho 6a69ecefb1 Merge pull request #372 from cbcoutinho/renovate/qdrant-qdrant-1.x
chore(deps): update qdrant/qdrant docker tag to v1.16.2
2025-12-04 13:56:27 +01:00
renovate-bot-cbcoutinho[bot] c05beb66e9 chore(deps): update qdrant/qdrant docker tag to v1.16.2 2025-12-04 11:09:16 +00:00
Chris Coutinho 34ddb24014 Merge pull request #368 from cbcoutinho/renovate/actions-checkout-digest
chore(deps): update actions/checkout digest to 8e8c483
2025-12-03 13:09:39 +01:00
Chris Coutinho 9d69613df7 Merge pull request #369 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6.0.1
2025-12-03 13:09:26 +01:00
github-actions[bot] 630f818538 bump: version 0.48.5 → 0.48.6 2025-12-03 12:09:01 +00:00
Chris Coutinho b280a720ff Merge pull request #370 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.15
2025-12-03 13:08:59 +01:00
Chris Coutinho 48bac9c212 Merge pull request #371 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.23,<1.24
2025-12-03 13:08:30 +01:00
renovate-bot-cbcoutinho[bot] e88c49fb50 fix(deps): update dependency mcp to >=1.23,<1.24 2025-12-03 11:13:29 +00:00
renovate-bot-cbcoutinho[bot] 9e10a5a400 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.15 2025-12-03 11:12:56 +00:00
renovate-bot-cbcoutinho[bot] 1dbea24fa2 chore(deps): update actions/checkout action to v6.0.1 2025-12-03 11:12:49 +00:00
renovate-bot-cbcoutinho[bot] 0606228b40 chore(deps): update actions/checkout digest to 8e8c483 2025-12-03 11:12:44 +00:00
Chris Coutinho f35b9f0988 Merge pull request #366 from cbcoutinho/renovate/anthropics-claude-code-action-digest
chore(deps): update anthropics/claude-code-action digest to 6337623
2025-12-02 13:17:39 +01:00
Chris Coutinho c400c46672 Merge pull request #367 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.14
2025-12-02 13:15:58 +01:00
renovate-bot-cbcoutinho[bot] fbdeb2161d chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.14 2025-12-02 11:08:38 +00:00
renovate-bot-cbcoutinho[bot] 8c7d03dd29 chore(deps): update anthropics/claude-code-action digest to 6337623 2025-12-02 11:08:33 +00:00
Chris Coutinho 135ce7b2df Merge pull request #364 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.7
2025-12-02 07:07:36 +01:00
Chris Coutinho 0e47ae051b Merge pull request #365 from cbcoutinho/renovate/softprops-action-gh-release-2.x
chore(deps): update softprops/action-gh-release action to v2.5.0
2025-12-01 15:43:03 +01:00
renovate-bot-cbcoutinho[bot] 04255473d2 chore(deps): update softprops/action-gh-release action to v2.5.0 2025-12-01 11:07:53 +00:00
renovate-bot-cbcoutinho[bot] ce6bbff389 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.7 2025-12-01 11:07:45 +00:00
Chris Coutinho 92c4bf36f6 perf(news): use direct API endpoint for get_item()
Replace O(n) fetch-all-and-filter approach with O(1) direct API call.
The News API v1-3 supports GET /items/{id} for single-item retrieval.

- Update get_item() to use direct endpoint
- Add unit test for get_item() method
- Fixes critical performance issue identified in code review

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 17:22:51 +01:00
Chris Coutinho 0bedbf1877 Merge remote-tracking branch 'origin/master' into feature/news-app-integration 2025-11-29 17:19:16 +01:00
Chris Coutinho a5cb6e1242 refactor(news): simplify vector sync to fetch all items
Remove the complex starred+unread filtering logic in scan_news_items().
The News app's auto-purge feature (default: 200 items per feed) already
limits the total number of items, making explicit filtering unnecessary.

Changes:
- Replace two API calls (starred + unread) with single all-items call
- Remove deduplication logic that merged both lists
- Update docstring to explain the simpler approach

This reduces code complexity while maintaining the same effective coverage.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 15:05:34 +01:00
Chris Coutinho a33f6a2f15 feat(news): add Nextcloud News app integration
Add full integration for the Nextcloud News (RSS/Atom reader) app:

- Add NewsClient with complete CRUD operations for folders, feeds, and items
- Add 8 read-only MCP tools for listing/getting folders, feeds, items
- Add Pydantic models for News entities with camelCase alias support
- Add vector sync support for starred + unread items
- Add HTML to Markdown converter using markdownify for better embeddings
- Add Docker post-install hook to enable News app
- Add 25 unit tests for NewsClient API methods

Vector sync indexes starred and unread items, providing a balanced approach
that captures important (starred) and current (unread) content without
indexing the entire article history.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-29 14:39:31 +01:00
Chris Coutinho d79e9090e6 Merge pull request #351 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin anthropics/claude-code-action action to a7e4c51
2025-11-29 12:39:10 +01:00
renovate-bot-cbcoutinho[bot] 97fd660e38 chore(deps): pin anthropics/claude-code-action action to a7e4c51 2025-11-29 11:05:15 +00:00
Chris Coutinho 96e168d035 Merge pull request #362 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-11-29 00:07:55 +01:00
renovate-bot-cbcoutinho[bot] 4d2b77ecaf chore(deps): update actions/checkout action to v6 2025-11-28 23:06:18 +00:00
github-actions[bot] e48da80a4b bump: version 0.48.4 → 0.48.5 2025-11-28 23:03:07 +00:00
Chris Coutinho 6125312f61 Merge pull request #313 from cbcoutinho/renovate/pillow-12.x
fix(deps): update dependency pillow to v12
2025-11-29 00:02:36 +01:00
claude[bot] 007fd0c2e3 chore: add Renovate package rule to block Pillow >=12.0.0
Pillow 12.x is incompatible with fastembed which requires pillow<12.0.0.
Added package rule to prevent Renovate from updating Pillow to version 12+
and reverted pyproject.toml to use pillow<12.0.0.

Co-authored-by: Chris Coutinho <cbcoutinho@users.noreply.github.com>
2025-11-28 23:01:46 +00:00
Chris Coutinho c4f90d6a57 Merge pull request #361 from cbcoutinho/add-claude-github-actions-1764370764331
Add Claude Code GitHub Workflow
2025-11-29 00:00:04 +01:00
Chris Coutinho 5dd62c9466 "Claude Code Review workflow" 2025-11-28 23:59:26 +01:00
Chris Coutinho 4d072d7217 "Claude PR Assistant workflow" 2025-11-28 23:59:25 +01:00
Chris Coutinho b4242b1394 Merge pull request #360 from cbcoutinho/renovate/docker-metadata-action-digest
chore(deps): update docker/metadata-action digest to c299e40
2025-11-28 00:07:01 +01:00
renovate-bot-cbcoutinho[bot] fa2343dff9 chore(deps): update docker/metadata-action digest to c299e40 2025-11-27 17:04:27 +00:00
Chris Coutinho 1b1667bc2b Merge pull request #357 from cbcoutinho/renovate/shivammathur-setup-php-digest
chore(deps): update shivammathur/setup-php digest to 44454db
2025-11-26 18:25:06 +01:00
Chris Coutinho c2b4bf9c67 Merge pull request #358 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.13
2025-11-26 18:24:46 +01:00
Chris Coutinho 0845fefe6c Merge pull request #359 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.1
2025-11-26 18:24:34 +01:00
renovate-bot-cbcoutinho[bot] d911556a84 chore(deps): update helm release qdrant to v1.16.1 2025-11-26 17:04:52 +00:00
renovate-bot-cbcoutinho[bot] 38be8d9401 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.13 2025-11-26 17:04:31 +00:00
renovate-bot-cbcoutinho[bot] 9f3190f62a chore(deps): update shivammathur/setup-php digest to 44454db 2025-11-26 17:04:26 +00:00
Chris Coutinho 41aeb7e0f2 Merge pull request #356 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.6
2025-11-26 00:50:25 +01:00
renovate-bot-cbcoutinho[bot] f8e67519e1 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.4.6 2025-11-25 23:06:05 +00:00
Chris Coutinho 4279dcba1e Merge pull request #354 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.12
2025-11-25 18:19:32 +01:00
Chris Coutinho be7e3d6b56 Merge pull request #355 from cbcoutinho/renovate/qdrant-qdrant-1.x
chore(deps): update qdrant/qdrant docker tag to v1.16.1
2025-11-25 18:19:07 +01:00
renovate-bot-cbcoutinho[bot] 41e128190b chore(deps): update qdrant/qdrant docker tag to v1.16.1 2025-11-25 17:06:22 +00:00
renovate-bot-cbcoutinho[bot] ba869ccde5 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.12 2025-11-25 17:06:11 +00:00
Chris Coutinho 27fe066b23 Merge pull request #353 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.2
chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 8cb1dc8
2025-11-23 19:41:19 +01:00
renovate-bot-cbcoutinho[bot] e94b8ff714 chore(deps): update docker.io/library/nextcloud:32.0.2 docker digest to 8cb1dc8 2025-11-23 17:04:03 +00:00
github-actions[bot] e3a6894904 bump: version 0.48.3 → 0.48.4 2025-11-23 16:40:06 +00:00
Chris Coutinho 92b97bda00 fix: Add rate limit retry logic to OpenAI provider
Add exponential backoff retry handling for OpenAI API rate limits
(429 errors). This is needed for GitHub Models API which has stricter
rate limits than standard OpenAI API.

- Add retry_on_rate_limit decorator with exponential backoff
- Max 5 retries with delays: 2s → 4s → 8s → 16s → 32s
- Apply to embed(), _embed_batch_request(), and generate() methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 17:24:48 +01:00
Chris Coutinho d5c6039296 ci: Update rag pipeline 2025-11-23 16:33:39 +01:00
Chris Coutinho 3fa13c8bfd ci: Update rag pipeline 2025-11-23 16:12:37 +01:00
Chris Coutinho 9d306b71fa ci: Fix pytest path 2025-11-23 15:43:45 +01:00
Chris Coutinho 38a936c120 Merge pull request #352 from cbcoutinho/renovate/major-github-artifact-actions
chore(deps): update actions/upload-artifact action to v5
2025-11-23 12:43:43 +01:00
renovate-bot-cbcoutinho[bot] 86d13a7240 fix(deps): update dependency pillow to v12 2025-11-23 05:05:03 +00:00
renovate-bot-cbcoutinho[bot] 0b2d449ffa chore(deps): update actions/upload-artifact action to v5 2025-11-23 05:04:36 +00:00
Chris Coutinho d881373dce ci: Remove third_party from app mounts 2025-11-23 05:48:17 +01:00
github-actions[bot] 9ade4c65f3 bump: version 0.48.2 → 0.48.3 2025-11-23 04:44:17 +00:00
Chris Coutinho 5c73b85f65 fix: Increase MCP sampling timeout to 5 minutes for slower LLMs
- Increase sampling timeout from 30s to 300s in semantic.py to accommodate
  slower local LLMs like Ollama
- Refactor RAG integration tests to support multiple providers (ollama,
  openai, anthropic, bedrock)
- Remove unnecessary embedding_provider fixture since MCP server handles
  embeddings internally
- Add --provider flag via tests/integration/conftest.py
- Add provider_fixtures.py with factory functions for generation providers

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 05:43:48 +01:00
github-actions[bot] f5764c01fc bump: version 0.48.1 → 0.48.2 2025-11-23 03:25:23 +00:00
Chris Coutinho 8c7c2a4407 Merge pull request #350 from cbcoutinho/feature/openai-provider-support
feature/openai provider support
2025-11-23 04:24:55 +01:00
Chris Coutinho 978de5e9a4 Merge branch 'master' into feature/openai-provider-support 2025-11-23 04:23:50 +01:00
Chris Coutinho 4e9859117c fix: Share vector sync state with FastMCP session lifespan via module singleton
The refactor in fafeaf3 moved background tasks to Starlette server lifespan
but broke nc_get_vector_sync_status because it still looked for streams in
FastMCP's AppContext (lifespan_context).

Add VectorSyncState module singleton to bridge the lifespans:
- starlette_lifespan sets the singleton when starting background tasks
- app_lifespan_basic reads from singleton and includes in AppContext
- MCP tools can now access document_receive_stream for pending count

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 04:20:47 +01:00
Chris Coutinho a134a0fc08 fix: Share vector sync state with FastMCP session lifespan via module singleton
The refactor in fafeaf3 moved background tasks to Starlette server lifespan
but broke nc_get_vector_sync_status because it still looked for streams in
FastMCP's AppContext (lifespan_context).

Add VectorSyncState module singleton to bridge the lifespans:
- starlette_lifespan sets the singleton when starting background tasks
- app_lifespan_basic reads from singleton and includes in AppContext
- MCP tools can now access document_receive_stream for pending count

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 04:20:09 +01:00
Chris Coutinho 6df58af0c3 ci: Decrease polling interval to 5s 2025-11-23 04:09:37 +01:00
github-actions[bot] 852606ec8b bump: version 0.48.0 → 0.48.1 2025-11-23 03:03:55 +00:00
Chris Coutinho caae6922be Merge pull request #349 from cbcoutinho/feature/openai-provider-support
feature/openai provider support
2025-11-23 04:03:29 +01:00
Chris Coutinho fafeaf3d83 refactor: Move background tasks to server lifespan and deprecate SSE transport
- Move scanner/processor tasks from FastMCP session lifespan to Starlette
  server lifespan (correct architecture: background tasks run once at
  server level, not per-session)
- Change default CLI transport from SSE to streamable-http
- Remove SSE transport option from CLI (SSE is deprecated)
- Remove SSE client session factory from test fixtures
- Add tracing instrumentation to BM25 hybrid search operations for
  better observability

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 04:02:30 +01:00
Chris Coutinho 2ab8dad6a5 fix: Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
- Change create_tag() to use WebDAV POST instead of OCS API which
  returned 404 in some Nextcloud versions
- Add llm_judge() helper that evaluates system output against ground
  truth with simple TRUE/FALSE prompt
- Replace keyword-based assertions in RAG tests with LLM judge for
  more flexible semantic evaluation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 02:24:01 +01:00
Chris Coutinho 50216accde Merge pull request #348 from cbcoutinho/feature/openai-provider-support
feature/openai provider support
2025-11-23 01:56:49 +01:00
Chris Coutinho bf2fdac2d0 ci: Fix health endpoint 2025-11-23 01:56:17 +01:00
github-actions[bot] 626c4bf562 bump: version 0.47.0 → 0.48.0 2025-11-23 00:53:24 +00:00
Chris Coutinho a56b3f3d51 Merge pull request #347 from cbcoutinho/feature/openai-provider-support
feature/openai provider support
2025-11-23 01:52:55 +01:00
Chris Coutinho 2896fa1dc9 feat: Add tag management methods to WebDAV client
- Add get_file_info() to get file info including file ID via PROPFIND
- Add create_tag() to create system tags via OCS API
- Add get_or_create_tag() for idempotent tag creation
- Add assign_tag_to_file() to assign tags to files via WebDAV
- Add remove_tag_from_file() to remove tags from files

Also refactors RAG evaluation:
- Add indexed_manual_pdf fixture using existing nc_client/nc_mcp_client
- Remove manual tag creation steps from workflow (now handled by fixture)
- Add comprehensive unit tests for new WebDAV methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 01:51:42 +01:00
Chris Coutinho 04251401aa ci: Add permissions to github token 2025-11-23 01:26:22 +01:00
github-actions[bot] e86b6e83ae bump: version 0.46.2 → 0.47.0 2025-11-23 00:23:47 +00:00
Chris Coutinho 6f5e75da15 Merge pull request #346 from cbcoutinho/feature/openai-provider-support
feat: Add OpenAI provider support for embeddings and generation
2025-11-23 01:23:18 +01:00
Chris Coutinho b2742aab80 ci: Add RAG evaluation workflow with workflow_dispatch
Adds a manually-triggered GitHub Actions workflow for RAG evaluation:
- Builds Nextcloud User Manual PDF from documentation source
- Uploads PDF to Nextcloud via WebDAV
- Tags file with 'vector-index' for vector sync indexing
- Waits for vector sync to complete
- Runs RAG integration tests with OpenAI/GitHub Models API

Inputs:
- embedding_model: OpenAI embedding model (default: openai/text-embedding-3-small)
- generation_model: OpenAI generation model (default: openai/gpt-4o-mini)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 01:22:16 +01:00
Chris Coutinho 208365cd3d feat: Add OpenAI provider support for embeddings and generation
Adds OpenAI provider to the unified provider architecture (ADR-015),
supporting:
- OpenAI API (api.openai.com)
- GitHub Models API (models.github.ai/inference)
- OpenAI-compatible endpoints (Fireworks, Together, etc.)

Features:
- Embedding support with text-embedding-3-small/large models
- Text generation via chat completions API
- Automatic retry with exponential backoff for rate limits
- Provider auto-detection in registry (priority after Bedrock)

Environment variables:
- OPENAI_API_KEY: API key (required)
- OPENAI_BASE_URL: Base URL override (optional)
- OPENAI_EMBEDDING_MODEL: Embedding model (default: text-embedding-3-small)
- OPENAI_GENERATION_MODEL: Generation model (default: gpt-4o-mini)

Also adds:
- Integration tests for RAG pipeline with MCP sampling
- MCP client sampling support for integration tests
- Ground truth Q&A pairs for Nextcloud User Manual

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-23 00:33:32 +01:00
Chris Coutinho 26f679d86e Merge pull request #332 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 b43ff04
2025-11-23 00:29:07 +01:00
Chris Coutinho cf39a15db1 Merge pull request #345 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.11
2025-11-23 00:28:53 +01:00
renovate-bot-cbcoutinho[bot] 1f3c35f162 chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.11 2025-11-22 23:04:43 +00:00
renovate-bot-cbcoutinho[bot] 2bccc3dad9 chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to b43ff04 2025-11-22 23:04:40 +00:00
github-actions[bot] 959cb8b21a bump: version 0.46.1 → 0.46.2 2025-11-22 21:02:53 +00:00
Chris Coutinho f8a2410a0a Merge pull request #344 from cbcoutinho/fix/smithery-json-response
fix(smithery): Enable JSON response format for scanner compatibility
2025-11-22 22:02:24 +01:00
Chris Coutinho 03b984d5a7 fix(smithery): Enable JSON response format for scanner compatibility
The Smithery scanner was reporting "0 tools" despite the server returning
valid tool definitions. Root cause: the server was returning SSE-formatted
responses (event: message\ndata: {...}) which the scanner couldn't parse.

Changes:
- Add json_response=True to FastMCP for Smithery stateless mode
- Clean up verbose docstring examples in semantic.py and webdav.py

The MCP spec allows both SSE and plain JSON responses for HTTP transport.
Setting json_response=True returns Content-Type: application/json with
plain JSON-RPC instead of text/event-stream with SSE format.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 22:01:18 +01:00
github-actions[bot] 57db18c6a3 bump: version 0.46.0 → 0.46.1 2025-11-22 18:54:11 +00:00
Chris Coutinho ea79e94842 Merge pull request #343 from cbcoutinho/fix/vector-viz-search
perf: Optimize vector viz search performance
2025-11-22 19:53:40 +01:00
Chris Coutinho b0612cfa0f perf: Optimize vector viz search performance
- Replace sequential Qdrant scroll calls with batch retrieve
  (50 HTTP requests → 1 request, ~50x faster vector fetch)

- Add point_id to SearchResult to enable batch retrieval by Qdrant point ID

- Reuse query embedding from search algorithm in viz_routes
  (eliminates redundant embedding call, saves ~30ms)

- Make BM25 encode() async with thread pool to avoid blocking event loop
  (~4.4s was blocking, now properly async)

- Run PCA computation in thread pool to avoid blocking event loop
  (~1.2s was blocking, now properly async)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 19:47:43 +01:00
github-actions[bot] 4e61d73da5 bump: version 0.45.0 → 0.46.0 2025-11-22 18:40:24 +00:00
Chris Coutinho 3b41776110 Merge pull request #342 from cbcoutinho/feature/smithery
feat: Add Smithery stateless deployment support (ADR-016)
2025-11-22 19:39:53 +01:00
Chris Coutinho 3e3d38696c docs(smithery): Make Smithery the primary Quick Start option
Reorganize README to promote Smithery as the fastest way to get started:
- Quick Start now features Smithery one-click deployment
- Docker instructions moved to separate "Docker (Self-Hosted)" section
- Added note about Smithery's stateless mode limitations

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 19:38:11 +01:00
Chris Coutinho 7b22e5be0f build: add smithery image to docker compose 2025-11-22 19:06:25 +01:00
Chris Coutinho 39fba49cfe fix(smithery): Add JSON Schema metadata to mcp-config endpoint
Add proper JSON Schema metadata fields per Smithery documentation:
- $schema: JSON Schema draft-07
- $id: Schema identifier URL
- title: Human-readable title
- description: Schema description
- x-query-style: "flat" (no nested objects in our schema)
- additionalProperties: false

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 18:28:05 +01:00
Chris Coutinho 706a15f0bc fix(smithery): Use container runtime pattern for config discovery
ADR-016: For container runtime deployment, Smithery does not auto-generate
the .well-known/mcp-config endpoint like it does for Python CLI runtime.

Changes:
- Remove [tool.smithery] from pyproject.toml (not used in container mode)
- Remove smithery_server.py (Python CLI runtime specific)
- Add .well-known/mcp-config endpoint to return JSON Schema config
- Add SmitheryConfigMiddleware to extract config from URL query params
- Use ContextVar to pass session config to tool handlers

The container runtime passes config as URL query parameters to /mcp:
  GET /mcp?nextcloud_url=...&username=...&app_password=...

Tested:
- All 164 unit tests passing
- Docker container builds successfully
- .well-known/mcp-config returns valid JSON Schema
- Health endpoints working

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 18:22:55 +01:00
Chris Coutinho b8dc413b73 feat: Add Smithery CLI deployment support
- Add smithery package as dependency
- Create smithery_server.py with @smithery.server() decorator
- Add SmitheryConfigSchema for session config (nextcloud_url, username, app_password)
- Add [tool.smithery] section to pyproject.toml
- Remove manual .well-known/mcp-config endpoint (Smithery handles this)

Smithery CLI will automatically:
- Extract config schema from the decorated function
- Handle session config parsing from query parameters
- Make config accessible via ctx.session_config in tools

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 18:05:33 +01:00
Chris Coutinho 8d29ce0122 fix: Add Smithery lifespan and auth mode detection
- Add SmitheryAppContext dataclass for stateless mode
- Add app_lifespan_smithery() with minimal lifespan (no shared state)
- Update is_oauth_mode() to detect Smithery mode and return BasicAuth
- Use Smithery lifespan when SMITHERY_DEPLOYMENT=true
- Add .well-known/mcp-config endpoint for config discovery
- Skip document processors in Smithery mode (not enabled)

Fixes startup issues in Smithery mode where missing env credentials
would incorrectly trigger OAuth mode.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:48:53 +01:00
Chris Coutinho a272e7cbab build: Fix Dockerfile.smithery 2025-11-22 17:35:16 +01:00
Chris Coutinho ce55b239e2 build: Fix Dockerfile.smithery 2025-11-22 17:33:12 +01:00
Chris Coutinho 432ab73741 build: Add missing deps 2025-11-22 17:32:20 +01:00
Chris Coutinho f93d650992 feat: Implement ADR-016 Smithery stateless deployment mode
Adds support for Smithery hosted deployment with stateless operation:

- Add DeploymentMode enum with SELF_HOSTED and SMITHERY_STATELESS modes
- Add get_deployment_mode() to detect mode from SMITHERY_DEPLOYMENT env var
- Update get_client() to create per-request clients from session config
- Add conditional tool registration (skip semantic search in Smithery mode)
- Add conditional /app admin UI mounting (skip in Smithery mode)
- Create smithery.yaml with configSchema for user credentials
- Create Dockerfile.smithery for minimal stateless container
- Create smithery_main.py entrypoint for Smithery deployment

In Smithery mode:
- Users provide nextcloud_url, username, app_password via session config
- Each request creates a fresh NextcloudClient (no state between requests)
- Semantic search tools are disabled (no vector database)
- Admin UI (/app) is disabled (no webhooks, vector viz)

All existing self-hosted functionality remains unchanged.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:30:42 +01:00
github-actions[bot] f9da19d1a1 bump: version 0.44.1 → 0.45.0 2025-11-22 16:14:35 +00:00
Chris Coutinho d2b6a26fe4 Merge pull request #341 from cbcoutinho/fix/async-await-and-pdf-metadata
fix: Async/await patterns, PDF metadata, and vector visualization improvements
2025-11-22 17:14:06 +01:00
Chris Coutinho 482ef89a73 docs: Add ADR-016 for Smithery stateless deployment
Add architecture decision record for supporting Smithery-hosted MCP
server in a stateless mode for multi-user public Nextcloud instances.

Key decisions:
- New SMITHERY_STATELESS deployment mode alongside SELF_HOSTED
- Session-based configuration (nextcloud_url, username, app_password)
- Feature subset excluding semantic search and background sync
- Admin UI (/app) excluded in Smithery mode
- Per-request client creation from session config

This enables users to try the MCP server without self-hosting
infrastructure while supporting multiple Nextcloud instances.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:13:18 +01:00
Chris Coutinho 34fd17ba55 fix: Use alpha_composite for proper RGBA highlight blending
Drawing directly with ImageDraw on RGBA mode doesn't blend alpha
properly. Use Image.alpha_composite() with a transparent overlay
to achieve correct semi-transparent highlight fills.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 17:04:29 +01:00
Chris Coutinho 8baa07db84 fix: Remove pymupdf.layout.activate() to fix page_chunks behavior
pymupdf.layout.activate() causes pymupdf4llm.to_markdown() to ignore the
page_chunks=True option, returning a single string instead of list[dict].
This broke per-page chunking needed for semantic search indexing.

See: https://github.com/pymupdf/pymupdf4llm/issues/323

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 16:58:35 +01:00
Chris Coutinho ba8a53803a refactor: Simplify PDF text extraction with single to_markdown call
Replace parallel per-page extraction with single to_markdown(page_chunks=True)
call. This is more efficient as pymupdf4llm can optimize internally for
full-document processing instead of making N separate calls for N pages.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 03:52:02 +01:00
Chris Coutinho 31fade9730 perf: Optimize PDF processing with parallel extraction and single-render highlights
Phase 1 - PDF Highlighting Optimization:
- Render each page ONCE instead of once per chunk (N chunks = 1 render, not N)
- Use PIL to draw bounding boxes on copied base images (fast) instead of
  re-rendering page via pymupdf (slow)
- Add _find_chunk_bbox() to extract bbox without modifying page

Phase 2 - Parallel Page Extraction:
- Use anyio task group with run_sync() for parallel page extraction
- Each page extracted in separate thread via anyio.to_thread.run_sync()
- Event loop stays responsive during extraction
- Remove obsolete _process_sync() method

Expected improvement: 30-50% reduction in total PDF processing time.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 03:11:56 +01:00
Chris Coutinho fffe483c02 fix: Centralize PDF processing and generate separate images per chunk
Previously, pymupdf4llm.to_markdown() was called twice - once in
PyMuPDFProcessor during indexing and again in PDFHighlighter during
visualization. Different image path lengths caused different character
offsets, leading to highlighted pages not matching their chunks.

Also fixed issue where all chunks on the same page showed all highlights
instead of just their own highlight. Now restores original page contents
between chunks using xref stream caching.

Changes:
- Add PDFHighlighter class requiring pre-computed page_boundaries and
  full_text from document processor (no fallback extraction)
- Pass pre-computed data from processor to highlighter
- Extract page-relative portion of chunk text for cross-page chunks
- Add bounding box highlighting using text anchor search
- Run highlight generation in parallel with embedding/BM25
- Cache and restore page contents to isolate highlights per chunk

Results: Highlighting success rate improved from 51% to 95% (121/128).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 02:46:30 +01:00
Chris Coutinho 8c79993280 Merge pull request #334 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 6cbef35
2025-11-21 14:24:54 +01:00
Chris Coutinho 8a0672a6be Merge pull request #339 from cbcoutinho/renovate/astral-sh-setup-uv-7.x
chore(deps): update astral-sh/setup-uv action to v7.1.4
2025-11-21 14:24:42 +01:00
Chris Coutinho 395f798ee2 Merge pull request #340 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.35.0
2025-11-21 14:24:26 +01:00
renovate-bot-cbcoutinho[bot] debff75221 chore(deps): update helm release ollama to v1.35.0 2025-11-21 11:09:18 +00:00
renovate-bot-cbcoutinho[bot] 4bf0a6c22e chore(deps): update astral-sh/setup-uv action to v7.1.4 2025-11-21 11:08:53 +00:00
Chris Coutinho fb025821cb Merge pull request #335 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.11
2025-11-21 09:45:31 +01:00
Chris Coutinho ff880fd4c9 Merge pull request #338 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.2
2025-11-21 09:34:20 +01:00
renovate-bot-cbcoutinho[bot] 03495d901d chore(deps): update docker.io/library/nextcloud docker tag to v32.0.2 2025-11-21 05:14:28 +00:00
github-actions[bot] 798958f20a bump: version 0.44.0 → 0.44.1 2025-11-21 00:39:23 +00:00
Chris Coutinho 699295c5be Merge pull request #336 from cbcoutinho/renovate/mcp-1.x
fix(deps): update dependency mcp to >=1.22,<1.23
2025-11-21 01:38:50 +01:00
Chris Coutinho a62a007c87 feat: Add context expansion to semantic search with chunk overlap removal
Implements optional context expansion for semantic search results that
fetches adjacent chunks (N-1 and N+1) from Qdrant to provide before/after
context. Removes configurable chunk overlap (default 200 chars) to avoid
duplicate text appearing in both context and excerpt.

Key changes:
- Add include_context and context_chars parameters to nc_semantic_search
  and nc_semantic_search_answer tools
- Implement Qdrant cache fast path for chunk retrieval (avoids re-fetching
  and re-parsing documents, especially important for PDFs)
- Add _get_chunk_by_index_from_qdrant() to fetch adjacent chunks
- Remove chunk overlap from before_context (last N chars) and after_context
  (first N chars) to prevent duplicate text
- Fetch context in parallel with anyio.Semaphore (max 20 concurrent)
- Pass through page_number from SearchResult to SemanticSearchResult
- Remove document-level deduplication (keep chunk-level dedup from algorithm)

Context expansion is opt-in via include_context=true parameter. When enabled:
- Populates has_context_expansion, marked_text, before_context, after_context
- Adds truncation flags when context exceeds context_chars limit
- Falls back to document fetch for legacy data with truncated excerpts

Related: nextcloud_mcp_server/search/context.py:87-382,
         nextcloud_mcp_server/server/semantic.py:161-255
2025-11-21 01:02:22 +01:00
renovate-bot-cbcoutinho[bot] d4fc1de80d fix(deps): update dependency mcp to >=1.22,<1.23 2025-11-20 23:11:11 +00:00
renovate-bot-cbcoutinho[bot] 0902b5653f chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.9.11 2025-11-20 23:10:47 +00:00
renovate-bot-cbcoutinho[bot] 0b6a02075c chore(deps): update docker.io/library/redis:alpine docker digest to 6cbef35 2025-11-20 23:10:43 +00:00
Chris Coutinho 7880a8de30 Merge pull request #333 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6
2025-11-20 20:17:21 +01:00
renovate-bot-cbcoutinho[bot] 2abedd6b4b chore(deps): update actions/checkout action to v6 2025-11-20 17:12:30 +00:00
Chris Coutinho 5a251a99e6 fix: Set is_placeholder=False in processor to fix search filtering
The processor was not setting is_placeholder field when writing real
document chunks to Qdrant. This caused the placeholder filter to exclude
all documents (since None != False), resulting in 0 search results.

Now explicitly sets is_placeholder: False in payload when writing real
indexed chunks, allowing search filters to correctly distinguish between
placeholders and real documents.
2025-11-20 17:15:19 +01:00
Chris Coutinho 25ef33de7f feat: Use Ollama native batch API in embed_batch()
- Switch from sequential loop to /api/embed batch endpoint
- Use 'input' array parameter instead of individual 'prompt' requests
- Process in chunks of 32 to avoid quality degradation (issue #6262)
- Reduces HTTP overhead: 128 texts = 4 requests instead of 128
- Maintains backward compatibility with embed() for single embeddings

Ref: ollama/ollama#6262
2025-11-20 16:50:13 +01:00
Chris Coutinho ec2c274cd9 fix: Increase placeholder staleness threshold to 5x scan interval
- Changed from 2x (120s) to 5x (300s) scan interval
- Large PDFs take 3-4 minutes to process, need longer threshold
- Prevents premature requeuing of in-flight documents
2025-11-20 15:36:49 +01:00
Chris Coutinho 47f0b3db9a fix: Add placeholder staleness check to prevent duplicate processing
- Only requeue documents if placeholder is older than 2x scan interval (120s default)
- Prevents scanner from immediately requeuing in-flight documents
- Fixes issue where PDFs were being reprocessed every 60 seconds
- Staleness check applied to both notes and files scanning logic
2025-11-20 15:30:10 +01:00
Chris Coutinho 233de3508f fix: Use empty SparseVector instead of None for placeholders
Qdrant validation rejects None for sparse vectors in named vector dicts.
Use models.SparseVector(indices=[], values=[]) instead to create valid
empty sparse vectors for placeholder points.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 15:15:10 +01:00
Chris Coutinho 13b2d0048c feat: Implement Qdrant placeholder state management
Introduces a placeholder-based state tracking system to prevent duplicate
document processing during the gap between scanner queuing and processor
completion.

**Key Changes:**

1. **Placeholder Helper Functions** (`vector/placeholder.py`):
   - `write_placeholder_point()` - Creates zero-vector placeholder when queuing
   - `query_document_metadata()` - Queries for existing entry (placeholder or real)
   - `delete_placeholder_point()` - Removes placeholder before writing real vectors
   - `get_placeholder_filter()` - Filters placeholders from user-facing queries

2. **Scanner Updates** (`vector/scanner.py`):
   - Replace `indexed_at` comparison with `modified_at` comparison
   - Write placeholder before queuing each document
   - Query per-document metadata instead of bulk-querying indexed_at
   - Fixes bug where files were resubmitted every scan cycle

3. **Processor Updates** (`vector/processor.py`):
   - Delete placeholder before upserting real vectors
   - Ensures no duplicate points in Qdrant

4. **Query Filters** (all search files):
   - Add `get_placeholder_filter()` to all user-facing queries
   - Ensures placeholders never appear in search results or visualizations
   - Applied to: bm25_hybrid.py, semantic.py, viz_routes.py, algorithms.py

**Architecture:**
- Placeholders use zero vectors with dimension from embedding service
- Payload includes `is_placeholder: True` flag for filtering
- Status field tracks: "pending", "processing", "completed", "failed"
- Deterministic UUIDs using uuid5 for consistent point IDs

**Impact:**
- Eliminates duplicate processing of same documents
- Fixes race condition where long-running documents get queued multiple times
- Prevents scanner from resubmitting files every scan cycle
- Maintains clean separation between in-flight and indexed documents

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 15:04:00 +01:00
Chris Coutinho 944dd760ca fix: Return empty array instead of null for query_coords when no results
When vector visualization search returns zero results, the code was returning
query_coords: null, which caused JavaScript error "can't access property 0,
queryCoords is null" when the frontend tried to access the array.

Changed to return empty array [] to match expected type and prevent crash.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 14:18:02 +01:00
Chris Coutinho d67aa6ae5c fix: Align PDF text extraction between indexing and context expansion
This commit fixes two critical issues with PDF processing:

1. **Text extraction mismatch (context expansion bug)**:
   - Indexing used pymupdf4llm.to_markdown() producing markdown text
   - Context expansion used page.get_text() producing plain text
   - Different text formats caused character offset misalignment
   - Search would find correct chunk, but expansion showed wrong section
   - Fixed by making context.py use pymupdf4llm.to_markdown() consistently

2. **Diagnostic logging for page number assignment**:
   - Added logging to verify page_boundaries exist in metadata
   - Added logging to verify assign_page_numbers() assigns values
   - Helps diagnose why page numbers show as null in search results

3. **mime_type storage bug**:
   - Fixed incorrect field reference in processor.py:405
   - Was using file_metadata.get("content_type", "")
   - Should use content_type from WebDAV response

Changes:
- nextcloud_mcp_server/search/context.py: Use pymupdf4llm.to_markdown()
  for PDF text extraction to match indexing method
- nextcloud_mcp_server/vector/processor.py: Add diagnostic logging for
  page boundaries and assignment, fix mime_type storage
- tests/unit/client/test_webdav.py: Fix import sorting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 13:57:50 +01:00
Chris Coutinho f1a5fac1b9 fix: Update models and viz to use int-only doc_id
- algorithms.py: Revert SearchResult.id to int (all docs use int IDs now)
- semantic.py: Revert SemanticSearchResult.id to int, remove Union import
- viz_routes.py: Remove str() conversion when querying doc_id from Qdrant
- viz_routes.py: Convert doc_id from query param to int in chunk context

Fixes vector visualization which was collapsing all chunks to a single
point because Qdrant queries were failing to match doc_id (string vs int).
2025-11-20 12:32:27 +01:00
Chris Coutinho d0691d5aa0 feat: Switch files to use numeric IDs with file_path resolution
- scanner.py: Use file_info['id'] as doc_id instead of file_path
- scanner.py: Pass file_path in DocumentTask for content retrieval
- processor.py: Store file_path in Qdrant payload for later lookup
- context.py: Add _get_file_path_from_qdrant() to resolve file_id → file_path
- context.py: Update get_chunk_with_context() to handle file ID resolution

This makes the system resilient to file renames since file IDs are stable
identifiers in Nextcloud, while file paths can change.
2025-11-20 12:00:47 +01:00
Chris Coutinho f1610bbd2e fix: Reconstruct full content for notes to match indexed offsets
Notes are indexed as "{title}\n\n{content}" in processor.py but were
being retrieved as just content during chunk expansion, causing
chunk_start_offset and chunk_end_offset to be misaligned.

This fix reconstructs the full content structure when fetching notes
for chunk expansion, ensuring the displayed chunks match the excerpts
shown in search results.

Fixes chunk/excerpt mismatch reported in vector visualization.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:33:12 +01:00
Chris Coutinho 327d843f64 feat: Implement per-chunk vector visualization with context expansion
Major improvements to vector visualization page:
- Refactor PCA to display individual chunks instead of averaged documents
- Add context expansion module for fetching surrounding text from notes and PDFs
- Update deduplication to use (doc_id, doc_type, chunk_start, chunk_end) keys
- Fix Alpine.js rendering with chunk-specific keys including offsets
- Refactor authentication helper to return NextcloudClient for better reuse
- Add async context manager support to NextcloudClient

Technical details:
- viz_routes.py: Fetch specific chunk vectors instead of averaging per document
- context.py: New module supporting both notes and PDF text extraction via PyMuPDF
- search algorithms: Extract page_number, chunk_index, total_chunks from Qdrant
- vector-viz.js/html: Use chunk positions in expansion tracking keys

This enables users to see which specific chunks match their query
and view them with surrounding context in the PCA visualization.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 11:22:20 +01:00
Chris Coutinho b8010270c1 fix: Add async/await, PDF metadata, and type safety fixes
This commit addresses multiple issues with async operations, PDF metadata
extraction, and type safety in document processing and search.

## Async/Await Fixes
- processor.py:259 - Added await for chunker.chunk_text(content)
- processor.py:270 - Added await for bm25_service.encode_batch(chunk_texts)
- tests/unit/test_document_chunker.py - Converted all 12 test methods to async

## PDF Metadata Enhancement
- pymupdf.py:143 - Added file_size metadata extraction
- pymupdf.py:145-206 - Refactored to extract text page-by-page
  - Manually loop through pages instead of using page_chunks=True
  - Generate page_boundaries metadata for precise page tracking
  - Works around pymupdf.layout.activate() breaking page_chunks=True
- processor.py:32-66 - Added assign_page_numbers() helper function
  - Assigns page numbers to chunks based on overlap with page boundaries
  - Handles chunks spanning multiple pages
- processor.py:298-300 - Call assign_page_numbers() for PDF files

## Type Safety Fixes
- bm25_hybrid.py:184 - Removed int() conversion of doc_id
- semantic.py:131 - Removed int() conversion of doc_id
- viz_routes.py:275 - Removed int() conversion of doc_id
- Added comments documenting that doc_id can be int (notes) or str (file paths)

## Testing
- All 18 tests passing (12 unit + 6 integration)
- No type errors in modified files
- Container logs show successful processing
- Vector viz searches working correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-20 02:37:07 +01:00
Chris Coutinho 0f24bdb17a docs: Add svg 2025-11-19 23:44:23 +01:00
github-actions[bot] bf11f16e2f bump: version 0.43.0 → 0.44.0 2025-11-19 22:43:03 +00:00
Chris Coutinho bf05ff8d6e Merge pull request #329 from cbcoutinho/feature/nextcloud-ui-improvements
feat: Redesign UI and improve vector visualization
2025-11-19 23:42:32 +01:00
Chris Coutinho c4ce28f05d fix: Improve 3D plot rendering with explicit dimensions and window resize support
- Get container dimensions before creating Plotly layout to render at correct size immediately
- Add init() method with window resize listener for responsive plot sizing
- Remove post-render resize call (no longer needed with explicit dimensions)
- Improve colorbar positioning and scene domain configuration

This eliminates the visual "jump" during initial render and ensures the plot resizes smoothly when the browser window changes size.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 19:43:20 +01:00
Chris Coutinho 9b2a06964b Merge pull request #331 from cbcoutinho/renovate/commitizen-tools-commitizen-action-0.x
chore(deps): update commitizen-tools/commitizen-action action to v0.26.0
2025-11-19 14:42:06 +01:00
Chris Coutinho c126c3ec03 fix: Preserve 3D plot camera and improve documentation
This commit addresses PR feedback and fixes plot camera behavior.

## JavaScript Fix - Camera Preservation
- Changed plot update strategy from recreating layout to using Plotly.restyle()
- Query point visibility now toggles via restyle() which only modifies trace visibility
- Camera position/zoom naturally preserved since layout remains untouched
- Resolves jumpy plot behavior when toggling "Show Query Point" checkbox

Related: nextcloud_mcp_server/auth/static/vector-viz.js:58-73

## Documentation Improvements
- Condensed vector-sync-ui.md from 316 to 94 lines (~70% reduction)
- Removed redundant FAQ section (content merged into main sections)
- Simplified use cases from 4 detailed sections to 3 focused paragraphs
- Streamlined troubleshooting to 3 common issues
- Merged technical details into overview section
- Retained all essential information while improving readability

## Screenshot Updates
Removed old/outdated images (5 files):
- rag-workflow-bidirectional-final.png
- rag-workflow-prominent-llm.png
- rag-workflow-simple-final.png
- vector-viz-interface.png
- welcome-page.png

Replaced with current screenshots (3 files):
- vector-viz-document-types-2col.png - Now shows plot + results
- vector-viz-chunk-context.png - Centered content view
- vector-viz-results.png - Updated results list

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 14:10:53 +01:00
Chris Coutinho 9bd02d7ef7 fix: Preserve 3D plot camera position and fix CSS loading
Two fixes for the vector visualization page:

1. **CSS Loading Fix**: Moved CSS <link> from vector_viz.html fragment
   to user_info.html <head> block. HTMX fragments don't process <link>
   tags in <head>, causing unstyled page. Now CSS loads correctly.

2. **Camera Preservation**: Modified renderPlot() to preserve camera
   position when toggling query point visibility. Previously, toggling
   the "Show Query Point" checkbox would reset zoom/rotation to default.
   Now reads existing camera settings from plot before updating.

Related: nextcloud_mcp_server/auth/static/vector-viz.js:123-130
Related: nextcloud_mcp_server/auth/templates/user_info.html:12

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 13:51:08 +01:00
renovate-bot-cbcoutinho[bot] e38a830f02 chore(deps): update commitizen-tools/commitizen-action action to v0.26.0 2025-11-19 11:07:37 +00:00
Chris Coutinho 18b753c3c7 Merge pull request #330 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.1
chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to d572839
2025-11-19 09:57:27 +01:00
renovate-bot-cbcoutinho[bot] b0735bae85 chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to d572839 2025-11-19 05:08:00 +00:00
Chris Coutinho 53689d076b feat: Improve vector visualization with static assets and fixes
- Extract CSS and JavaScript into separate static files
  - Created nextcloud_mcp_server/auth/static/vector-viz.css
  - Created nextcloud_mcp_server/auth/static/vector-viz.js
  - Updated templates to reference external assets

- Fix vector visualization issues:
  - Normalize vectors before PCA to match Qdrant's cosine distance
  - Add zero-norm and NaN detection/handling for large datasets
  - Enable responsive Plotly sizing (autosize + responsive config)
  - Widen plot area to full viewport width with minimized margins

- Improve visualization accuracy:
  - Query point now positioned correctly relative to documents
  - Handles 200+ points without JSON serialization errors
  - Full-width plot maximizes screen space utilization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 04:10:44 +01:00
Chris Coutinho 0f7d6c0e33 Merge pull request #327 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 2e683fc
2025-11-19 01:53:05 +01:00
Chris Coutinho 16701fdb72 Merge pull request #328 from cbcoutinho/renovate/docker.io-library-redis-alpine
chore(deps): update docker.io/library/redis:alpine docker digest to 5013e94
2025-11-19 01:52:57 +01:00
Chris Coutinho 9db20a4d01 feat: Redesign UI to match Nextcloud ecosystem aesthetic
This commit updates the web interface to better align with Nextcloud's
design system and improve the Vector Viz layout.

Changes:
- Replace emoji icons with Material Design SVG icons for better
  consistency with Nextcloud apps
- Simplify navigation styling with minimal padding and subtle active
  states (250px width)
- Update CSS variables to match Nextcloud design system
- Restructure Vector Viz from two-column to single-column vertical
  layout for better plot visibility
- Move search controls to compact horizontal grid at top
- Make navigation toggle always visible (not just on mobile)
- Fix plot container sizing with overflow:visible to prevent colorbar
  clipping
- Remove heavy shadows and custom card styling for cleaner aesthetic
- Add error and success page templates with consistent styling

Technical details:
- Preserve Alpine.js for reactive functionality
- Use CSS Grid for responsive horizontal controls layout
- Add smooth transitions for navigation collapse/expand
- Maintain HTMX for dynamic content loading

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 00:45:19 +01:00
renovate-bot-cbcoutinho[bot] 7ddf8370e6 chore(deps): update docker.io/library/redis:alpine docker digest to 5013e94 2025-11-18 23:10:41 +00:00
renovate-bot-cbcoutinho[bot] 98dff98e9c chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 2e683fc 2025-11-18 23:10:36 +00:00
Chris Coutinho 73e8012707 Merge pull request #325 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 2bbc83f
2025-11-18 14:06:14 +01:00
Chris Coutinho c2fd87a5d3 Merge pull request #324 from cbcoutinho/renovate/docker.io-library-nextcloud-32.0.1
chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to f6232ea
2025-11-18 14:03:38 +01:00
github-actions[bot] 441d94301e bump: version 0.42.0 → 0.43.0 2025-11-18 12:56:15 +00:00
Chris Coutinho b488d69939 Merge pull request #326 from cbcoutinho/feature/notes2
feat: Replace custom document chunker with LangChain MarkdownTextSplitter
2025-11-18 13:55:34 +01:00
Chris Coutinho eec923eff5 feat: Replace custom document chunker with LangChain MarkdownTextSplitter
Migrates from custom word-based chunking to LangChain's MarkdownTextSplitter
for better semantic search quality. This implements the chunking portion of
ADR-011.

Changes:
- Replace custom regex word chunker with MarkdownTextSplitter
- Optimized for Markdown content (headers, code blocks, lists)
- Convert from word-based (512 words) to character-based (2048 chars) chunking
- Maintain backward-compatible ChunkWithPosition interface
- Update configuration defaults and validation
- Update all unit tests (12/12 passing)

Benefits:
- Respects markdown structure boundaries
- Never breaks code blocks or headers mid-chunk
- Preserves semantic coherence within chunks
- Expected 20-30% improvement in recall quality
- Industry-standard approach (used by production RAG systems)

Note: Full reindex required to apply new chunking to existing documents.
Current vector database still contains old word-based chunks.

Related: ADR-011 (Improving Semantic Search Quality)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 12:17:23 +01:00
renovate-bot-cbcoutinho[bot] 3642faf32c chore(deps): update docker.io/library/python:3.12-slim-trixie docker digest to 2bbc83f 2025-11-18 11:08:08 +00:00
renovate-bot-cbcoutinho[bot] 3b1cd96722 chore(deps): update docker.io/library/nextcloud:32.0.1 docker digest to f6232ea 2025-11-18 11:08:03 +00:00
Chris Coutinho 219d064459 Merge pull request #321 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin ghcr.io/astral-sh/uv docker tag to 29bd450
2025-11-18 00:15:32 +01:00
Chris Coutinho d0ab8d071a Merge pull request #322 from cbcoutinho/renovate/actions-checkout-digest
chore(deps): update actions/checkout digest to 93cb6ef
2025-11-18 00:15:20 +01:00
Chris Coutinho b792e9d9a3 Merge pull request #323 from cbcoutinho/renovate/docker.io-library-mariadb-lts
chore(deps): update docker.io/library/mariadb:lts docker digest to 1cac849
2025-11-18 00:14:46 +01:00
renovate-bot-cbcoutinho[bot] 4288814ff4 chore(deps): update docker.io/library/mariadb:lts docker digest to 1cac849 2025-11-17 23:11:14 +00:00
renovate-bot-cbcoutinho[bot] f34a1c5677 chore(deps): update actions/checkout digest to 93cb6ef 2025-11-17 23:11:10 +00:00
renovate-bot-cbcoutinho[bot] 6d48f90112 chore(deps): pin ghcr.io/astral-sh/uv docker tag to 29bd450 2025-11-17 23:11:04 +00:00
Chris Coutinho b72aeca55f test: Add custom notes app 2025-11-17 22:14:01 +01:00
Chris Coutinho c1ae818b75 Merge pull request #317 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-latest
chore(deps): update ghcr.io/astral-sh/uv:latest docker digest to 29bd450
2025-11-17 19:40:24 +01:00
Chris Coutinho ebca2bfc70 build: pin uv to 0.9.10, use --no-cache 2025-11-17 19:33:15 +01:00
Chris Coutinho 6dcd0bae48 Merge pull request #318 from cbcoutinho/renovate/actions-checkout-5.x
chore(deps): update actions/checkout action to v5.0.1
2025-11-17 19:23:32 +01:00
Chris Coutinho 818f643dca Merge pull request #319 from cbcoutinho/renovate/qdrant-1.x
chore(deps): update helm release qdrant to v1.16.0
2025-11-17 19:23:25 +01:00
Chris Coutinho d31b490f13 Merge pull request #320 from cbcoutinho/renovate/qdrant-qdrant-1.x
chore(deps): update qdrant/qdrant docker tag to v1.16.0
2025-11-17 19:23:16 +01:00
renovate-bot-cbcoutinho[bot] 839cf159b8 chore(deps): update qdrant/qdrant docker tag to v1.16.0 2025-11-17 17:09:02 +00:00
renovate-bot-cbcoutinho[bot] cefb438017 chore(deps): update helm release qdrant to v1.16.0 2025-11-17 17:08:54 +00:00
renovate-bot-cbcoutinho[bot] efc78a835e chore(deps): update actions/checkout action to v5.0.1 2025-11-17 17:08:34 +00:00
renovate-bot-cbcoutinho[bot] fa25a1b4df chore(deps): update ghcr.io/astral-sh/uv:latest docker digest to 29bd450 2025-11-17 17:08:28 +00:00
github-actions[bot] 8367208a03 bump: version 0.41.0 → 0.42.0 2025-11-17 07:25:33 +00:00
Chris Coutinho 52acc4bc07 Merge pull request #316 from cbcoutinho/feature/cleanup
feat(viz): Add dual-score display and improve UI controls
2025-11-17 08:25:04 +01:00
Chris Coutinho d374bfa1e5 feat(viz): Add dual-score display and improve UI controls
This commit enhances the vector visualization interface with better score
transparency and improved UX:

**Dual-Score Display:**
- Store original algorithm scores before normalization (viz_routes.py:203)
- Display both raw and normalized scores: "Raw Score: 0.842 (89% relative)"
- Update plot hover text with dual scores (userinfo_routes.py:740)
- Fixes issue where all queries showed at least one 100% match regardless
  of actual relevance (normalization artifact)

**UI Improvements:**
1. Fusion Method dropdown: Changed from x-show to :disabled
   - Prevents jarring layout shift when switching algorithms
   - Dropdown stays visible but grayed out when Semantic is selected
   - Better UX with opacity: 0.5 and cursor: not-allowed

2. Score Threshold: Changed step from 0.1 to "any"
   - Allows arbitrary float precision (0.7, 0.85, 0.123)
   - Users can now fine-tune threshold values

3. Document Types: Converted multi-select to checkbox grid
   - Replaced clunky Ctrl/Cmd multi-select listbox
   - Checkbox grid with cleaner layout
   - Positioned left of Score Threshold and Result Limit inputs
   - More intuitive UX

**Technical Details:**
- Raw score ranges vary by algorithm:
  - Semantic: 0.0-1.0 (cosine similarity)
  - BM25 RRF: ~0.001-0.033 (Reciprocal Rank Fusion)
  - BM25 DBSF: Can exceed 1.0 (Distribution-Based Score Fusion)
- Normalized scores (0-1) used for visual encoding (marker size, color)
- Original scores preserved in API response via getattr fallback

Files modified:
- nextcloud_mcp_server/auth/viz_routes.py (store original_score)
- nextcloud_mcp_server/auth/templates/vector_viz.html (UI controls)
- nextcloud_mcp_server/auth/userinfo_routes.py (plot hover text)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 08:05:49 +01:00
github-actions[bot] b1f7b1d30b bump: version 0.40.0 → 0.41.0 2025-11-17 05:57:12 +00:00
Chris Coutinho b8bdbb499f Merge pull request #315 from cbcoutinho/feature/cleanup
Feature/cleanup
2025-11-17 06:56:43 +01:00
Chris Coutinho 2522b13d35 ci: Add unit tests to ci 2025-11-17 06:51:40 +01:00
Chris Coutinho 6cfd7e2729 feat: add configurable fusion algorithms for BM25 hybrid search
Added support for two fusion algorithms (RRF and DBSF) to combine dense
semantic and sparse BM25 search results, with comprehensive documentation
and unit tests.

Changes:
- Added fusion parameter to nc_semantic_search and nc_semantic_search_answer tools
- Updated ADR-014 with detailed comparison of RRF vs DBSF fusion algorithms
- Added unit tests for fusion algorithm initialization and validation
- Updated search_method in responses to include fusion type (e.g., "bm25_hybrid_rrf")

Fusion Algorithms:
- RRF (Reciprocal Rank Fusion): Default, rank-based, general-purpose
- DBSF (Distribution-Based Score Fusion): Score normalization using statistics

RRF is recommended for most use cases due to its robustness and established
track record. DBSF may provide better results when retrieval systems have
very different score distributions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 06:48:43 +01:00
Chris Coutinho 3aa7128f45 feat: add chunk position tracking to vector indexing and search
Track character offsets (start_offset, end_offset) for each chunk in vector
database metadata, enabling precise chunk highlighting in visualization pane.

Changes:
- processor.py: Store chunk_start_offset and chunk_end_offset in Qdrant metadata
- processor.py: Added metadata_version=2 to indicate position tracking support
- search/semantic.py: Return chunk positions from search results
- server/semantic.py: Expose chunk positions in API responses (SemanticSearchResult)

Enables viz pane to:
1. Display exact matched chunk with surrounding context
2. Highlight the precise portion of text that matched the query
3. Build user trust by showing what the RAG system actually retrieved

Position tracking uses ChunkWithPosition dataclass from document_chunker.py
which provides character-accurate offsets in the original document.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 06:47:58 +01:00
Chris Coutinho c3282534eb feat: add vector viz template and chunk context endpoint
Extracted vector visualization HTML template to separate file to resolve
syntax conflicts between Jinja2, Alpine.js, and CSS. Added chunk context
endpoint for fetching matched chunks with surrounding text.

Changes:
- Moved vector_viz.html to templates/ directory (separates Jinja2/Alpine.js/CSS)
- Added /app/chunk-context endpoint for retrieving chunk text with context
- Updated .dockerignore to include HTML files in Docker builds
- Moved anthropic and boto3 to main dependencies (needed for production features)
- Added jinja2 dependency for template rendering

Fixes Jinja2 TemplateSyntaxError caused by CSS colons being parsed as
Jinja2 syntax when template was inline in Python code.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 06:46:52 +01:00
Chris Coutinho 862308418e fix: prevent infinite loop in DocumentChunker with position tracking
Fixed a critical infinite loop bug in document_chunker.py that occurred
when the overlap parameter caused the chunker to not make forward progress.

Changes:
- Added ChunkWithPosition dataclass to track character positions
- Refactored chunk_text() to use regex word matching for accurate position tracking
- Added safety check to ensure forward progress (next_start_idx > start_idx)
- Changed return type from list[str] to list[ChunkWithPosition]

The bug manifested when:
1. end_idx reached len(word_matches) (processing last chunk)
2. next_start_idx = end_idx - overlap would not advance past start_idx
3. Loop would continue indefinitely without making progress

Fix ensures chunker always terminates by breaking when not advancing.

All 9 unit tests now pass in 1.66s (previously timing out at 180s).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 06:39:15 +01:00
Chris Coutinho 3464b21845 fix: Relax SearchResult validation to support DBSF fusion scores > 1.0
Fix false-positive validation error where DBSF (Distribution-Based Score
Fusion) correctly produces scores > 1.0 but SearchResult validation
incorrectly rejected them.

**Root Cause**: SearchResult.__post_init__() enforced scores in [0.0, 1.0]
range, but DBSF sums normalized scores from multiple retrieval systems
(dense semantic + sparse BM25), resulting in scores like 1.55 when both
systems strongly agree a document is relevant.

**Changes**:
- Relaxed validation to allow any score ≥ 0.0 (algorithms.py:147-157)
- Updated SearchResult and SemanticSearchResult documentation to explain
  score ranges for RRF ([0.0, 1.0]) vs DBSF (unbounded)
- Added comprehensive test coverage for both fusion methods
- Added DBSF fusion option to vector visualization UI
- Updated viz routes and vizApp() to support fusion parameter selection

**Testing**: All 157 unit tests pass, type checking passes, ruff passes

Fixes error: "Configuration error: Score must be between 0.0 and 1.0, got 1.1528953"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-17 06:32:30 +01:00
Chris Coutinho ea01ce7673 Merge pull request #311 from cbcoutinho/renovate/python-replacement
chore(deps): replace python docker tag with docker.io/library/python
2025-11-16 12:11:52 +01:00
Chris Coutinho 216cb94383 Merge branch 'master' into renovate/python-replacement 2025-11-16 12:11:36 +01:00
Chris Coutinho 5f3e0b84a3 Merge pull request #310 from cbcoutinho/renovate/pin-dependencies
chore(deps): pin dependencies
2025-11-16 12:10:57 +01:00
github-actions[bot] 39131cefcc bump: version 0.39.0 → 0.40.0 2025-11-16 11:09:40 +00:00
Chris Coutinho 9498c0fa36 Merge pull request #309 from cbcoutinho/feature/bedrock
feat: Unified Provider Architecture + Amazon Bedrock Support
2025-11-16 12:09:12 +01:00
Chris Coutinho ed33b39062 docs: fix ADR-014 template text and numbering
- Remove template instruction text from line 1
- Fix ADR numbering from 007 to 014 to match filename
2025-11-16 12:08:37 +01:00
Chris Coutinho 1504df6fb5 Merge branch 'master' into feature/bedrock 2025-11-16 12:08:23 +01:00
renovate-bot-cbcoutinho[bot] 392e1536b9 chore(deps): replace python docker tag with docker.io/library/python 2025-11-16 11:07:34 +00:00
renovate-bot-cbcoutinho[bot] 00ed3f07e5 chore(deps): pin dependencies 2025-11-16 11:07:28 +00:00
github-actions[bot] 050e9a56b9 bump: version 0.38.0 → 0.39.0 2025-11-16 11:02:48 +00:00
Chris Coutinho 7fccd47722 Merge pull request #304 from cbcoutinho/feature/bm25
feat: Replace custom keyword search with BM25 hybrid search via Qdrant
2025-11-16 12:02:18 +01:00
Chris Coutinho f65b95ef07 Update Dockerfile 2025-11-16 11:58:13 +01:00
Chris Coutinho c28fc955ca Merge origin/master into feature/bm25
Resolved conflicts:
- viz_routes.py: Kept bm25's extract_dense_vector() function for robust vector handling
- hybrid.py: Removed (bm25 uses native Qdrant RRF fusion instead)
- uv.lock: Regenerated after accepting master's dependencies

This merge brings in:
- RAG evaluation framework (ADR-013)
- Performance optimizations (double-fetch elimination)
- Migration from asyncio to anyio
- OpenTelemetry tracing improvements
- Notes app enhancements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 11:52:40 +01:00
Chris Coutinho ad4b45889f fix: suppress Starlette middleware type warnings in ty checker 2025-11-16 11:43:50 +01:00
Chris Coutinho 5b484c9226 feat: add unified provider architecture with Amazon Bedrock support
Refactored LLM provider infrastructure to support sustainable additions of new providers with both embedding and text generation capabilities.

## Major Changes

### Unified Provider Architecture (ADR-015)
- Created `nextcloud_mcp_server/providers/` with unified Provider ABC
- Providers now support optional capabilities (embeddings and/or generation)
- Auto-detection registry with priority: Bedrock → Ollama → Simple
- Backward compatible - existing code continues to work

### New Providers
- **BedrockProvider**: Full Amazon Bedrock integration
  - Embeddings: Titan Embed, Cohere Embed models
  - Generation: Claude, Llama, Titan Text, Mistral models
  - Model-specific request/response handling
  - AWS credential chain integration
- **OllamaProvider**: Migrated with both capabilities support
- **AnthropicProvider**: Moved from test code to production providers
- **SimpleProvider**: Migrated in-memory fallback provider

### Breaking Changes
None - full backward compatibility maintained:
- `embedding.get_embedding_service()` still works
- RAG evaluation tests updated to use unified providers
- All existing tests pass (127 unit tests)

### Testing
- Added 9 comprehensive Bedrock unit tests with mocked boto3
- All existing unit tests pass
- Type checking (ty) and linting (ruff) pass
- Verified backward compatibility

### Documentation
- `docs/ADR-015-unified-provider-architecture.md`: Comprehensive ADR
- `docs/bedrock-setup.md`: AWS setup guide with IAM permissions
- `CLAUDE.md`: Updated with provider architecture section

### Dependencies
- Added `boto3>=1.35.0` to dev dependencies (optional)

## Environment Variables

### Bedrock
- `AWS_REGION`: AWS region (e.g., "us-east-1")
- `BEDROCK_EMBEDDING_MODEL`: Model ID for embeddings
- `BEDROCK_GENERATION_MODEL`: Model ID for generation
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`: Optional credentials

### Ollama
- `OLLAMA_BASE_URL`: API URL
- `OLLAMA_EMBEDDING_MODEL`: Embedding model (default: "nomic-embed-text")
- `OLLAMA_GENERATION_MODEL`: Generation model

## AWS Bedrock Permissions Required

Minimal IAM policy:
```json
{
  "Effect": "Allow",
  "Action": ["bedrock:InvokeModel"],
  "Resource": ["arn:aws:bedrock:*::foundation-model/*"]
}
```

See `docs/bedrock-setup.md` for detailed setup instructions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 11:36:58 +01:00
Chris Coutinho f559ca049e Merge branch 'rag-evaluation' 2025-11-16 10:26:19 +01:00
Chris Coutinho 8e7b3c3ded Merge branch 'feature/notes' 2025-11-16 09:18:58 +01:00
Chris Coutinho c74695af16 Merge branch 'feature/notes' 2025-11-16 08:28:00 +01:00
Chris Coutinho 1faf572546 Merge branch 'feature/bm25'
Resolves conflict in viz_routes.py by combining:
- Named vector extraction from feature/bm25
- Performance timing from master
2025-11-16 08:18:39 +01:00
Chris Coutinho 944b6dcf5a fix: Handle named vectors in visualization and semantic search
- viz_routes.py: Extract "dense" vector from named vector dict
- semantic.py: Specify using="dense" for BM25 hybrid collections
- Fixes "X must be 2D array" error in hybrid search
- Fixes "Dense vector  is not found" error in semantic search

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 08:16:35 +01:00
Chris Coutinho 2aa82d849c Merge branch 'feature/bm25' 2025-11-16 07:57:36 +01:00
Chris Coutinho fc6a2f14e4 fix: Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
The visualization UI was still using the old 'hybrid' algorithm name and
weight parameters that were replaced by the BM25 hybrid search refactor.
This caused "Unknown algorithm: hybrid" errors when using the search
& visualize feature.

Changes:
- Update default algorithm from 'hybrid' to 'bm25_hybrid'
- Update default scoreThreshold from 0.7 to 0.0 to match backend
- Remove deprecated semanticWeight, keywordWeight, fuzzyWeight parameters
- Remove weight parameters from search request

Fixes the visualization search functionality after BM25 hybrid refactor.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 07:54:20 +01:00
Chris Coutinho d1fb7eb633 Merge branch 'rag-evaluation' 2025-11-16 07:46:17 +01:00
Chris Coutinho 96cee48258 build: Migrate image to debian-based 2025-11-16 07:32:01 +01:00
Chris Coutinho 16c22c953b fix: Update viz routes to use BM25 hybrid search after refactor
- Remove obsolete search algorithm imports (Fuzzy, Keyword, Hybrid)
- Update UI to only show Semantic and BM25 Hybrid algorithms
- Replace manual weight controls with RRF fusion info message
- Update default algorithm from "hybrid" to "bm25_hybrid"
- Remove weight parameters (semantic_weight, keyword_weight, fuzzy_weight)
- Update score_threshold default from 0.7 to 0.0 for RRF scoring
- Document ty type checker in CLAUDE.md

Fixes unresolved-import type errors after BM25 refactor.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 07:23:11 +01:00
Chris Coutinho 6fe5596c13 feat: Implement BM25 hybrid search with native Qdrant RRF fusion
Replace custom keyword/fuzzy search algorithms with industry-standard BM25
sparse vectors, combined with dense semantic vectors using Qdrant's native
Reciprocal Rank Fusion (RRF). This consolidates search architecture and
improves relevance for both semantic and keyword queries.

Key changes:
- Add fastembed dependency for BM25 sparse vector generation
- Update Qdrant collection schema to support named vectors (dense + sparse)
- Create BM25SparseEmbeddingProvider using FastEmbed's Qdrant/bm25 model
- Implement BM25HybridSearchAlgorithm with native Qdrant RRF prefetch
- Update document processor to generate both dense and sparse embeddings
- Simplify nc_semantic_search() tool to use BM25 hybrid only
- Remove legacy keyword.py, fuzzy.py, and custom hybrid.py (736 lines)
- Update ADR-014 with implementation notes and test results

Benefits:
- Consolidated architecture (single Qdrant database)
- Native database-level RRF fusion (more efficient)
- Industry-standard BM25 (replaces brittle custom keyword search)
- Better relevance across semantic and keyword queries
- Simplified codebase (-285 net lines)

Tests: All 125 tests passing (118 unit, 7 integration)

Implements ADR-014: Replace Custom Keyword Search with BM25 Hybrid Search

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-16 06:59:44 +01:00
Chris Coutinho f5bc3e3bc3 docs: init ADR 2025-11-16 06:24:25 +01:00
262 changed files with 51434 additions and 4763 deletions
+2
View File
@@ -5,3 +5,5 @@
!uv.lock
!nextcloud_mcp_server/**/*.py
!nextcloud_mcp_server/**/*.html
!nextcloud_mcp_server/auth/static/*
+145 -13
View File
@@ -7,26 +7,158 @@ on:
jobs:
bump-version:
if: "!startsWith(github.event.head_commit.message, 'bump:')"
if: "!startsWith(github.event.head_commit.message, 'bump:') && !startsWith(github.event.head_commit.message, 'chore(release):')"
runs-on: ubuntu-latest
name: "Bump version and create changelog with commitizen"
name: "Bump version and create changelog for monorepo components"
permissions:
contents: write
packages: write
steps:
- name: Check out
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Create bump and changelog
uses: commitizen-tools/commitizen-action@9615e7be1cf341393c52e865ebbdaa0712176d81 # 0.25.0
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
token: ${{ secrets.GITHUB_TOKEN }}
python-version: '3.11'
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Detect and bump component versions
id: bump
run: |
set -euo pipefail
# Track which components were bumped
BUMPED_COMPONENTS=""
# Helper function to check for commits with specific scope since last tag
has_commits_since_tag() {
local tag_pattern="$1"
local scope_pattern="$2"
# Get the most recent tag matching the pattern
local last_tag=$(git tag --sort=-creatordate | grep -E "^${tag_pattern}" | head -n 1 || echo "")
if [ -z "$last_tag" ]; then
# No previous tag, check all commits on master
local commit_range="master"
else
# Check commits since last tag
local commit_range="${last_tag}..HEAD"
fi
# Count commits matching the scope pattern
local commit_count=$(git log "$commit_range" --oneline --grep="^${scope_pattern}" -E | wc -l)
if [ "$commit_count" -gt 0 ]; then
echo "Found $commit_count commits for scope '$scope_pattern' since $last_tag"
return 0
else
echo "No commits found for scope '$scope_pattern' since $last_tag"
return 1
fi
}
# Bump MCP server (default - all commits except helm scope)
echo "Checking MCP server for version bump..."
# Get the most recent MCP tag
last_mcp_tag=$(git tag --sort=-creatordate | grep -E "^v[0-9]" | head -n 1 || echo "")
if [ -z "$last_mcp_tag" ]; then
commit_range="master"
else
commit_range="${last_mcp_tag}..HEAD"
fi
# Count conventional commits that are NOT scoped to helm
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
{ grep -v "(helm)" || true; } | wc -l)
MCP_BUMPED=false
if [ "$mcp_commit_count" -gt 0 ]; then
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
echo "Bumping MCP server version..."
./scripts/bump-mcp.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
MCP_BUMPED=true
else
echo "No commits found for MCP server since $last_mcp_tag"
fi
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
echo "Checking Helm chart for version bump..."
HELM_HAS_COMMITS=false
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
HELM_HAS_COMMITS=true
fi
if [ "$HELM_HAS_COMMITS" = true ]; then
echo "Bumping Helm chart version (helm-scoped commits)..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
elif [ "$MCP_BUMPED" = true ]; then
echo "Bumping Helm chart version (appVersion changed)..."
./scripts/bump-helm.sh --increment PATCH
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
fi
# Output summary
if [ -z "$BUMPED_COMPONENTS" ]; then
echo "No components required version bumps"
echo "bumped=false" >> $GITHUB_OUTPUT
else
echo "Bumped components:$BUMPED_COMPONENTS"
echo "bumped=true" >> $GITHUB_OUTPUT
echo "components=$BUMPED_COMPONENTS" >> $GITHUB_OUTPUT
fi
- name: Push tags
if: steps.bump.outputs.bumped == 'true'
run: |
git push
git push --tags
echo "Pushed tags for components:${{ steps.bump.outputs.components }}"
- name: Summary
run: |
if [ "${{ steps.bump.outputs.bumped }}" == "true" ]; then
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
else
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ No version bumps required - no relevant commits found since last release." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The workflow completed successfully with no changes." >> $GITHUB_STEP_SUMMARY
fi
+58
View File
@@ -0,0 +1,58 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+7 -6
View File
@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
on:
push:
tags: ["*"]
tags:
- "v*"
jobs:
build-and-push:
@@ -12,11 +13,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
# list of Docker images to use as base name for tags
images: |
@@ -33,18 +34,18 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # 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 }}
+4 -1
View File
@@ -4,6 +4,7 @@ on:
push:
tags:
- v*
- nextcloud-mcp-server-*
jobs:
release:
@@ -14,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -38,6 +39,8 @@ jobs:
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
with:
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+105
View File
@@ -0,0 +1,105 @@
name: RAG Evaluation
on:
workflow_dispatch:
inputs:
manual_path:
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
required: false
default: 'Nextcloud Manual.pdf'
embedding_model:
description: 'OpenAI embedding model'
required: false
default: 'openai/text-embedding-3-small'
generation_model:
description: 'OpenAI generation model'
required: false
default: 'openai/gpt-4o-mini'
jobs:
rag-evaluation:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
models: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with:
compose-file: |
./docker-compose.yml
./docker-compose.ci.yml
up-flags: "--build"
env:
# Environment variables passed to docker-compose.ci.yml
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Wait for Nextcloud to be ready
run: |
echo "Waiting for Nextcloud..."
max_attempts=60
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "Service did not become ready in time."
exit 1
fi
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
sleep 5
done
echo "Nextcloud is ready."
- name: Wait for MCP server to be ready
run: |
echo "Waiting for MCP server..."
max_attempts=30
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health/live | grep -q "200"; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "MCP server did not become ready in time."
exit 1
fi
echo "Attempt $attempt/$max_attempts: MCP not ready, sleeping for 2 seconds..."
sleep 2
done
echo "MCP server is ready."
- name: Run RAG evaluation tests
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
run: |
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
- name: Capture MCP container logs
if: always()
run: |
echo "=== MCP Container Logs ==="
docker compose logs mcp --tail=500
- name: Upload test results
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: rag-evaluation-results
path: |
pytest-results.xml
retention-days: 30
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+162 -37
View File
@@ -1,4 +1,4 @@
name: Docker Compose Action
name: Tests
on:
pull_request:
@@ -9,80 +9,205 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install the latest version of uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Check format
run: |
uv run --frozen ruff format --diff
run: uv run --frozen ruff format --diff
- name: Linting
run: |
uv run --frozen ruff check
- name: Linting
run: |
uv run --frozen ty check -- nextcloud_mcp_server
run: uv run --frozen ruff check
- name: Type check
run: uv run --frozen ty check -- nextcloud_mcp_server
unit-test:
runs-on: ubuntu-latest
needs: [linting]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Run unit tests
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
integration-test:
runs-on: ubuntu-latest
needs: [linting]
strategy:
fail-fast: false
matrix:
nextcloud_version:
- "31"
- "32"
# - "33" # Disabled until all upstream apps support NC 33
mode:
- "single-user"
- "multi-user-basic"
- "oauth"
- "login-flow"
include:
# Version-specific image pins — Renovate updates these via customManagers
# renovate: datasource=docker depName=docker.io/library/nextcloud
- nextcloud_version: "31"
nextcloud_image: "docker.io/library/nextcloud:31.0.14@sha256:9bf3fae91aad4dca3eff02c1f71df8d5c6705a349065fb537aa5c5ef578f1013"
# renovate: datasource=docker depName=docker.io/library/nextcloud
- nextcloud_version: "32"
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
# renovate: datasource=docker depName=docker.io/library/nextcloud
# Disabled until all upstream apps support NC 33
# - nextcloud_version: "33"
# nextcloud_image: "docker.io/library/nextcloud:33.0.0@sha256:d53f6cb35b0712aa890a5e4a8ca21043d6fcd390f38c55b710816dd7cbc2edc0"
# Mode-specific properties
- mode: single-user
profile: single-user
markers: "(smoke and not oauth and not keycloak and not login_flow and not multi_user_basic) or (integration and not oauth and not keycloak and not login_flow and not multi_user_basic)"
wait-port: 8000
mcp-internal-url: "http://mcp:8000"
needs-playwright: false
extra-args: >-
--ignore=tests/integration/test_qdrant_collection_creation.py
--ignore=tests/rag_evaluation/
- mode: multi-user-basic
profile: multi-user-basic
markers: "multi_user_basic"
wait-port: 8003
mcp-internal-url: "http://mcp-multi-user-basic:8000"
needs-playwright: true
extra-args: ""
- mode: oauth
profile: oauth
markers: "oauth and not keycloak"
wait-port: 8001
mcp-internal-url: "http://mcp-oauth:8001"
needs-playwright: true
extra-args: ""
- mode: login-flow
profile: login-flow
markers: "login_flow"
wait-port: 8004
mcp-internal-url: "http://mcp-login-flow:8004"
needs-playwright: true
extra-args: ""
name: integration (${{ matrix.mode }} / nc${{ matrix.nextcloud_version }})
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
submodules: 'true'
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
- name: Set up PHP 8.4
if: matrix.mode != 'single-user'
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
# OIDC app installed from app store (dev mount removed from docker-compose.yml)
- name: Set up Node.js
if: matrix.mode != 'single-user'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Build Astrolabe app
if: matrix.mode != 'single-user'
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
cd third_party/astrolabe
composer install --no-dev --optimize-autoloader
npm ci
npm run build
# Start services with the appropriate profile
- name: Run docker compose
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with:
compose-file: "./docker-compose.yml"
#compose-flags: "--profile qdrant"
compose-flags: "--profile ${{ matrix.profile }}"
up-flags: "--build"
env:
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
- name: Install the latest version of uv
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install Playwright dependencies
run: |
uv run playwright install chromium --with-deps
- name: Install Playwright
if: matrix.needs-playwright
run: uv run playwright install chromium --with-deps
- name: Wait for service to be ready
# Wait for Nextcloud to be healthy
- name: Wait for Nextcloud
run: |
echo "Waiting for service at http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info to return 401..."
echo "Waiting for Nextcloud at http://localhost:8080..."
max_attempts=60
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
until curl -sSf http://localhost:8080/status.php 2>/dev/null | grep -q '"installed":true'; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "Service did not become ready in time."
echo "Nextcloud did not become ready in time."
docker compose logs app
exit 1
fi
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
sleep 5
done
echo "Service is ready (returned 401)."
echo "Nextcloud is ready."
# Add subsequent steps here, e.g., running tests
- name: Run tests
# Wait for the MCP service to be healthy
- name: Wait for MCP service (${{ matrix.mode }})
run: |
echo "Waiting for MCP service on port ${{ matrix.wait-port }}..."
max_attempts=30
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:${{ matrix.wait-port }}/health 2>/dev/null | grep -qE "200|404|405"; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "MCP service did not become ready in time."
docker compose --profile ${{ matrix.profile }} logs
exit 1
fi
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
sleep 5
done
echo "MCP service is ready on port ${{ matrix.wait-port }}."
- name: Verify OIDC configuration
if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
run: |
echo "=== OIDC Discovery ==="
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
echo "=== OIDC App Status ==="
docker compose exec -T app php occ app:list --output=json 2>/dev/null | jq '.enabled.oidc // "NOT INSTALLED"'
- name: Run tests (${{ matrix.mode }})
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run pytest -v --log-cli-level=WARN -m smoke
uv run pytest -v \
--log-cli-level=WARN \
-m '${{ matrix.markers }}' \
-o "addopts=-p no:asyncio" \
--timeout=300 \
${{ matrix.extra-args }}
- name: Collect service logs on failure
if: failure()
run: docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: debug-${{ matrix.mode }}-nc${{ matrix.nextcloud_version }}
path: |
/tmp/*.png
/tmp/docker-compose-logs.txt
retention-days: 7
if-no-files-found: ignore
+6 -3
View File
@@ -1,6 +1,9 @@
[submodule "oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
[submodule "third_party/oidc"]
path = third_party/oidc
url = https://github.com/cbcoutinho/oidc
[submodule "third_party/notes"]
path = third_party/notes
url = https://github.com/cbcoutinho/notes
[submodule "third_party/astrolabe"]
path = third_party/astrolabe
url = https://github.com/cbcoutinho/astrolabe
+651
View File
@@ -1,3 +1,654 @@
# Changelog - MCP Server
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.65.0 (2026-03-03)
### Feat
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
- add Docker Compose profiles and Login Flow v2 service
### Fix
- replace assert with proper guard and invalidate scope cache after provisioning
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
- address review feedback — security, caching, CI 429 retry
- skip keycloak hook when profile inactive and update stale PRM test
- address remaining PR #589 review findings
- address PR #589 review findings
- address PR review issues for Login Flow v2
- address PR #589 review feedback (round 2)
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
- **ci**: fix health check timeout and per-profile MCP server URL routing
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
- address PR #589 review feedback for Login Flow v2
- **ci**: fix integration test collection and skip Playwright in CI
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
- **ci**: keep third_party mount, always build submodules in CI
- **ci**: revert accidental third_party mount, use compose override for OIDC
- **ci**: don't block integration matrix on unit-test failures
## v0.64.5 (2026-03-03)
### Fix
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
## v0.64.4 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## v0.64.3 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## v0.64.2 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## v0.64.1 (2026-02-18)
### Fix
- **deps**: update dependency mcp to >=1.26,<1.27
## v0.64.0 (2026-02-16)
### Feat
- add self-signed SSL certificate support for Nextcloud connections
### Fix
- add type: ignore for caldav ssl_verify_cert parameter
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
## v0.63.5 (2026-02-16)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## v0.63.4 (2026-02-08)
### Fix
- strip whitespace from category names when splitting
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
## v0.63.3 (2026-02-08)
### Fix
- expand recurring events in date-range queries
## v0.63.2 (2026-02-07)
### Fix
- use CalDAV time-range filter for calendar date range queries
## v0.63.1 (2026-02-03)
### Fix
- **helm**: add backward compatibility for legacy persistence configs
## v0.63.0 (2026-01-28)
### Feat
- **astrolabe**: add background token refresh job
### Fix
- **astrolabe**: add pagination and psalm fixes for token refresh
- **astrolabe**: add locking to prevent token refresh race condition
- **astrolabe**: add issued_at to on-demand token refresh
## v0.62.0 (2026-01-26)
### Feat
- **scripts**: add database query helpers for development
### Fix
- **astrolabe**: resolve Psalm type errors in PDF preview code
- **astrolabe**: fix Psalm baseline and ESLint import order
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
- **astrolabe**: improve error messages for authorization issues
- **astrolabe**: rename OAuthController and fix app password check
- **tests**: improve Astrolabe integration test reliability
- **astrolabe**: update Plotly title attributes for v3 compatibility
- **deps**: update dependency plotly.js-dist-min to v3
### Refactor
- **api**: split management.py into domain-focused modules
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
## v0.61.5 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## v0.61.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## v0.61.3 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## v0.61.2 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
## v0.61.1 (2026-01-15)
### Fix
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## v0.61.0 (2026-01-14)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## v0.60.4 (2026-01-12)
### Fix
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
## v0.60.3 (2025-12-31)
### Fix
- **deck**: Always preserve fields in update_card for partial updates
- **astrolabe**: Fix CSS loading for Nextcloud apps
- **astrolabe**: Fix revoke access button HTTP method mismatch
## v0.60.2 (2025-12-29)
### Fix
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
## v0.60.1 (2025-12-26)
### Fix
- **mcp**: Move all imports to the top of modules
## v0.60.0 (2025-12-26)
### Feat
- Remove URL rewriting in favor of proper nextcloud config
- **helm**: migrate to new environment variable naming convention
- Migrate to vue 3
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
### Fix
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
- **auth**: Skip issuer validation for management API tokens
- Use settings.enable_offline_access for env var consolidation
- Add required config.py attributes
- **docker**: remove overwritehost to fix container-to-container DCR
- **deps**: update dependency @nextcloud/vue to v9
- **deps**: update dependency vue to v3
### Refactor
- **auth**: Decouple BasicAuth and OAuth authentication strategies
## v0.59.1 (2025-12-22)
### Fix
- **helm**: set OIDC client env vars when using existingSecret
- **helm**: trigger chart release workflow on helm chart tags
## v0.59.0 (2025-12-22)
### Feat
- **helm**: add support for multi-user BasicAuth mode
### Fix
- **helm**: address PR #447 reviewer feedback
- **helm**: include MCP server version bumps in changelog pattern
## v0.58.0 (2025-12-22)
### Feat
- **config**: enable DCR for multi-user BasicAuth with offline access
- **astrolabe**: implement app password provisioning for multi-user background sync
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
## v0.57.0 (2025-12-20)
### Feat
- **auth**: add multi-user BasicAuth pass-through mode
- **astrolabe**: add dynamic MCP server configuration for testing
### Fix
- **config**: address reviewer feedback
### Refactor
- **config**: centralize configuration validation and simplify startup
## v0.56.2 (2025-12-20)
### Fix
- **astrolabe**: screenshots in info.xml
- **astrolabe**: screenshots in info.xml
## v0.56.1 (2025-12-19)
### Fix
- **astrolabe**: Update screenshots
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
## v0.56.0 (2025-12-19)
### Feat
- **ci**: add --increment flag to bump scripts for manual version control
### Fix
- **astrolabe**: add contents:write permission to appstore workflow
- **astrolabe**: update commitizen pattern to properly update info.xml version
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
- **astrolabe**: info.xml
## v0.55.1 (2025-12-19)
### Fix
- **ci**: push all tags explicitly in bump workflow
## v0.55.0 (2025-12-19)
### BREAKING CHANGE
- MCP server now bumps for ANY conventional commit except
those explicitly scoped to helm or astrolabe.
### Feat
- **ci**: implement monorepo-aware version bumping workflow
### Fix
- **ci**: make MCP server default bump target for all non-scoped commits
- **ci**: restrict docker build to MCP server tags only
- **ci**: correct appstore-push-action version to v1.0.4
## v0.54.0 (2025-12-19)
### Feat
- **astrolabe**: add Nextcloud App Store deployment automation
- configure commitizen monorepo with independent versioning
### Fix
- **ci**: improve versioning and error handling
- **ci**: address critical workflow and validation issues
- **astrolabe**: address code review feedback
## v0.53.0 (2025-12-19)
### Feat
- add Alembic database migration system
- make chunk modal title clickable link to documents
- add native Plotly hover styling for clickable points
- add click interactivity to Plotly 3D scatter chart
- improve chunk viewer with fixed navigation and markdown rendering
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
- **auth**: implement refresh token rotation for Nextcloud OIDC
- **astrolabe**: enhance unified search and add webhook management
- **astrolabe**: add webhook management UI to admin settings
- **astrolabe**: add OAuth token refresh and webhook presets
- **search**: add file_path metadata and chunk offsets to search results
- **astrolabe**: use proper icons and thumbnails in unified search
- **astrolabe**: add admin search settings and enhanced UI
- **astrolabe**: add unified search provider with clickable file links
- **astrolabe**: add 3D PCA visualization for semantic search
- **astrolabe**: add Nextcloud PHP app for MCP server management
- **vector-sync**: enable background sync in OAuth mode
### Fix
- **security**: address critical security issues from PR #401 code review
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
- **astrolabe**: revert invalid files_pdfviewer URL for file links
- resolve type checking warnings for CI
- move Alembic to package submodule for Docker compatibility
- update unified search results to match chunk viz display
- **astrolabe**: handle OAuth refresh token rotation
- address critical code review issues (4 fixes)
- resolve CI linting issues for Astroglobe
### Refactor
- **astrolabe**: extract PDF viewer to dedicated component
- **astrolabe**: reframe UI as semantic search service
## v0.52.1 (2025-12-13)
### Perf
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
## v0.52.0 (2025-12-13)
### Feat
- **vector**: add Deck card vector search with visualization support
## v0.51.0 (2025-12-13)
### Feat
- **vector-viz**: add news_item support for links and chunk expansion
## v0.50.2 (2025-12-13)
### Fix
- **news**: revert get_item() to use get_items() + filter
## v0.50.1 (2025-12-12)
### Fix
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.50.0 (2025-12-11)
### Feat
- add MCP tool annotations for enhanced UX
### Fix
- address PR review feedback
## v0.49.2 (2025-12-09)
### Fix
- Update lockfile
## v0.49.1 (2025-12-09)
### Fix
- Revert mcp version <1.23
## v0.49.0 (2025-12-08)
### Feat
- **news**: add Nextcloud News app integration
### Fix
- resolve all type checking errors (8 errors fixed)
### Refactor
- **news**: simplify vector sync to fetch all items
### Perf
- **news**: use direct API endpoint for get_item()
## v0.48.6 (2025-12-03)
### Fix
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.48.5 (2025-11-28)
### Fix
- **deps**: update dependency pillow to v12
## v0.48.4 (2025-11-23)
### Fix
- Add rate limit retry logic to OpenAI provider
## v0.48.3 (2025-11-23)
### Fix
- Increase MCP sampling timeout to 5 minutes for slower LLMs
## v0.48.2 (2025-11-23)
### Fix
- Share vector sync state with FastMCP session lifespan via module singleton
- Share vector sync state with FastMCP session lifespan via module singleton
## v0.48.1 (2025-11-23)
### Fix
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
### Refactor
- Move background tasks to server lifespan and deprecate SSE transport
## v0.48.0 (2025-11-23)
### Feat
- Add tag management methods to WebDAV client
## v0.47.0 (2025-11-23)
### Feat
- Add OpenAI provider support for embeddings and generation
## v0.46.2 (2025-11-22)
### Fix
- **smithery**: Enable JSON response format for scanner compatibility
## v0.46.1 (2025-11-22)
### Perf
- Optimize vector viz search performance
## v0.46.0 (2025-11-22)
### Feat
- Add Smithery CLI deployment support
- Implement ADR-016 Smithery stateless deployment mode
### Fix
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
- **smithery**: Use container runtime pattern for config discovery
- Add Smithery lifespan and auth mode detection
## v0.45.0 (2025-11-22)
### Feat
- Add context expansion to semantic search with chunk overlap removal
- Use Ollama native batch API in embed_batch()
- Implement Qdrant placeholder state management
- Switch files to use numeric IDs with file_path resolution
- Implement per-chunk vector visualization with context expansion
### Fix
- Use alpha_composite for proper RGBA highlight blending
- Remove pymupdf.layout.activate() to fix page_chunks behavior
- Centralize PDF processing and generate separate images per chunk
- Set is_placeholder=False in processor to fix search filtering
- Increase placeholder staleness threshold to 5x scan interval
- Add placeholder staleness check to prevent duplicate processing
- Use empty SparseVector instead of None for placeholders
- Return empty array instead of null for query_coords when no results
- Align PDF text extraction between indexing and context expansion
- Update models and viz to use int-only doc_id
- Reconstruct full content for notes to match indexed offsets
- Add async/await, PDF metadata, and type safety fixes
### Refactor
- Simplify PDF text extraction with single to_markdown call
### Perf
- Optimize PDF processing with parallel extraction and single-render highlights
## v0.44.1 (2025-11-21)
### Fix
- **deps**: update dependency mcp to >=1.22,<1.23
## v0.44.0 (2025-11-19)
### Feat
- Improve vector visualization with static assets and fixes
- Redesign UI to match Nextcloud ecosystem aesthetic
### Fix
- Improve 3D plot rendering with explicit dimensions and window resize support
- Preserve 3D plot camera and improve documentation
- Preserve 3D plot camera position and fix CSS loading
## v0.43.0 (2025-11-18)
### Feat
- Replace custom document chunker with LangChain MarkdownTextSplitter
## v0.42.0 (2025-11-17)
### Feat
- **viz**: Add dual-score display and improve UI controls
## v0.41.0 (2025-11-17)
### Feat
- add configurable fusion algorithms for BM25 hybrid search
- add chunk position tracking to vector indexing and search
- add vector viz template and chunk context endpoint
### Fix
- prevent infinite loop in DocumentChunker with position tracking
- Relax SearchResult validation to support DBSF fusion scores > 1.0
## v0.40.0 (2025-11-16)
### Feat
- add unified provider architecture with Amazon Bedrock support
### Fix
- suppress Starlette middleware type warnings in ty checker
## v0.39.0 (2025-11-16)
### Feat
- Implement BM25 hybrid search with native Qdrant RRF fusion
### Fix
- Handle named vectors in visualization and semantic search
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
- Update viz routes to use BM25 hybrid search after refactor
## v0.38.0 (2025-11-16)
### Feat
+196 -2
View File
@@ -17,13 +17,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- **Use Python 3.10+ union syntax**: `str | None` instead of `Optional[str]`
- **Use lowercase generics**: `dict[str, Any]` instead of `Dict[str, Any]`
- **Type all function signatures** - Parameters and return types
- **No explicit type checker configured** - Ruff handles linting only
- **Type checker**: `ty` is configured for static type checking
```bash
uv run ty check -- nextcloud_mcp_server
```
### Code Quality
- **Run ruff before committing**:
- **Run ruff and ty before committing**:
```bash
uv run ruff check
uv run ruff format
uv run ty check -- nextcloud_mcp_server
```
- **Ruff configuration** in pyproject.toml (extends select: ["I"] for import sorting)
@@ -52,13 +56,127 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
### MCP Tool Annotations (ADR-017)
**All tools MUST include annotations** following these patterns:
```python
from mcp.types import ToolAnnotations
# Read-only tools (list, search, get)
@mcp.tool(
title="Human Readable Name",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True, # Nextcloud is external to MCP server
),
)
# Create operations
@mcp.tool(
title="Create Resource",
annotations=ToolAnnotations(
idempotentHint=False, # Creates new resources each time
openWorldHint=True,
),
)
# Update operations (with etag/version control)
@mcp.tool(
title="Update Resource",
annotations=ToolAnnotations(
idempotentHint=False, # ETag changes = different inputs
openWorldHint=True,
),
)
# Delete operations
@mcp.tool(
title="Delete Resource",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Same end state if called repeatedly
openWorldHint=True,
),
)
# HTTP PUT without version control (special case)
@mcp.tool(
title="Write File",
annotations=ToolAnnotations(
idempotentHint=True, # Same content = same end state
openWorldHint=True,
),
)
```
**Key Principles**:
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
- **Destructive**: Operations that permanently delete/overwrite data
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
- **Titles**: Use human-readable names, not snake_case function names
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
### Project Structure
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
- `nextcloud_mcp_server/auth/` - OAuth/OIDC authentication
- `nextcloud_mcp_server/models/` - Pydantic response models
- `nextcloud_mcp_server/providers/` - Unified LLM provider infrastructure (embeddings + generation)
- `tests/` - Layered test suite (unit, smoke, integration, load)
### Provider Architecture (ADR-015)
**Unified Provider System** for embeddings and text generation:
**Location:** `nextcloud_mcp_server/providers/`
- `base.py` - `Provider` ABC with optional capabilities
- `registry.py` - Auto-detection and factory pattern
- `ollama.py` - Ollama provider (embeddings + generation)
- `anthropic.py` - Anthropic provider (generation only)
- `bedrock.py` - Amazon Bedrock provider (embeddings + generation)
- `simple.py` - Simple in-memory provider (embeddings only, fallback)
**Usage:**
```python
from nextcloud_mcp_server.providers import get_provider
provider = get_provider() # Auto-detects from environment
# Check capabilities
if provider.supports_embeddings:
embeddings = await provider.embed_batch(texts)
if provider.supports_generation:
text = await provider.generate("prompt", max_tokens=500)
```
**Environment Variables:**
Bedrock:
- `AWS_REGION` - AWS region (e.g., "us-east-1")
- `BEDROCK_EMBEDDING_MODEL` - Embedding model ID (e.g., "amazon.titan-embed-text-v2:0")
- `BEDROCK_GENERATION_MODEL` - Generation model ID (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
- `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` - Optional, uses AWS credential chain
Ollama:
- `OLLAMA_BASE_URL` - API URL (e.g., "http://localhost:11434")
- `OLLAMA_EMBEDDING_MODEL` - Embedding model (default: "nomic-embed-text")
- `OLLAMA_GENERATION_MODEL` - Generation model (e.g., "llama3.2:1b")
- `OLLAMA_VERIFY_SSL` - SSL verification (default: "true")
Simple (fallback, no config needed):
- `SIMPLE_EMBEDDING_DIMENSION` - Dimension (default: 384)
**Auto-Detection Priority:** Bedrock → Ollama → Simple
**Backward Compatibility:**
- Old code using `nextcloud_mcp_server.embedding.get_embedding_service()` still works
- `EmbeddingService` now wraps `get_provider()` internally
**For Details:** See `docs/ADR-015-unified-provider-architecture.md`
## Development Commands (Quick Reference)
### Testing
@@ -121,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
### Quick Query Script (Recommended for Agents)
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
```bash
# Basic query
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
# Vertical output (one column per line) - useful for wide tables
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
# With different credentials
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
```
### Direct Docker Access
For interactive sessions or complex operations:
```bash
# Connect to database
docker compose exec db mariadb -u root -ppassword nextcloud
@@ -146,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
- `oc_oidc_redirect_uris` - Redirect URIs
### SQLite Databases (MCP Services)
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
```bash
# List tables
./scripts/sqlitequery.py ".tables"
# Query specific service
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
# With column headers
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
# JSON output
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
# View schema
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
```
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
**SQLite Tables**:
- `refresh_tokens` - OAuth refresh tokens with user profiles
- `audit_logs` - Security audit trail
- `oauth_clients` - DCR OAuth client credentials
- `oauth_sessions` - OAuth flow session state
- `registered_webhooks` - Webhook registrations
- `app_passwords` - Multi-user BasicAuth passwords
- `alembic_version` - Migration tracking
## Architecture Quick Reference
**For detailed architecture, see:**
@@ -388,6 +559,29 @@ docker compose exec app php occ user_oidc:provider keycloak
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
### Querying Nextcloud Application Logs
**Use this pattern** to inspect Nextcloud application logs during debugging:
```bash
# View recent log entries
docker compose exec app cat /var/www/html/data/nextcloud.log | jq | tail
# Filter by app
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.app == "astrolabe")' | tail
# Filter by log level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=FATAL)
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.level >= 3)' | tail
# Search for specific messages
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.message | contains("OAuth"))' | tail -20
# View full exception traces
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.exception != null)' | tail -5
```
**Log Structure**: Each entry is a JSON object with fields: `reqId`, `level`, `time`, `remoteAddr`, `user`, `app`, `method`, `url`, `message`, `userAgent`, `version`, `exception`
**For detailed setup, see**:
- `docs/installation.md` - Installation guide
- `docs/configuration.md` - Configuration options
+106
View File
@@ -0,0 +1,106 @@
# Contributing to Nextcloud MCP Server
## Version Management
This monorepo uses commitizen for version management with **independent versioning** for two components:
### Components
| Component | Scope | Bump Command | Tag Example |
|-----------|-------|--------------|-------------|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
### Commit Message Format
Use conventional commits with **scopes** to target specific components:
```bash
# MCP server changes
feat(mcp): add calendar sync API
fix(mcp): resolve authentication bug
# Helm chart changes
feat(helm): add resource limits
docs(helm): update values documentation
```
**Unscoped commits** default to the MCP server:
```bash
feat: add new feature # → MCP server (v0.54.0)
```
### Release Workflow
#### 1. Make Changes with Scoped Commits
```bash
git commit -m "feat(helm): add ingress annotations"
git commit -m "feat(mcp): add calendar sync"
```
#### 2. Bump Component Versions
```bash
# Bump MCP server (reads commits with scope=mcp or unscoped)
./scripts/bump-mcp.sh
# → Creates tag: v0.54.0
# → Updates: pyproject.toml, Chart.yaml:appVersion
# Bump Helm chart (reads commits with scope=helm)
./scripts/bump-helm.sh
# → Creates tag: nextcloud-mcp-server-0.54.0
# → Updates: Chart.yaml:version
```
#### 3. Push Tags
```bash
git push --follow-tags
```
### Changelog Filtering
Each component maintains its own `CHANGELOG.md`:
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
### Manual Version Bumps
For specific increments:
```bash
# Patch bump (0.53.0 → 0.53.1)
uv run cz bump --increment PATCH
# Minor bump (0.53.0 → 0.54.0)
uv run cz bump --increment MINOR
# Major bump (0.53.0 → 1.0.0)
uv run cz bump --increment MAJOR
# For non-MCP components, use --config
cd charts/nextcloud-mcp-server
uv run cz --config .cz.toml bump --increment MINOR
```
### Versioning Philosophy
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
### Chart.yaml Version vs appVersion
The Helm chart has TWO version fields:
- **`version`**: Chart packaging version (bumped by `feat(helm):`)
- Example: `0.53.0``0.54.0` when adding resource limits
- **`appVersion`**: MCP server version being deployed (bumped by `feat(mcp):`)
- Example: `"0.53.0"``"0.54.0"` when MCP server releases
This allows the chart to evolve independently from the application.
+15 -4
View File
@@ -1,17 +1,28 @@
FROM ghcr.io/astral-sh/uv:0.9.9-python3.11-alpine@sha256:0faa7934fac1db7f5056f159c1224d144bab864fd2677a4066d25a686ae32edd
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
# 2. sqlite for development with token db
RUN apk add --no-cache git sqlite
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
git \
tesseract-ocr \
sqlite3 && apt clean
WORKDIR /app
COPY pyproject.toml uv.lock README.md .
RUN uv sync --locked --no-dev --no-install-project --no-cache
COPY . .
RUN uv sync --locked --no-dev --no-editable
RUN uv sync --locked --no-dev --no-editable --no-cache
ENV PYTHONUNBUFFERED=1
ENV VIRTUAL_ENV=/app/.venv
ENV PATH=/app/.venv/bin:$PATH
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "run", "--host", "0.0.0.0"]
+44
View File
@@ -0,0 +1,44 @@
# Dockerfile for Smithery stateless deployment
# ADR-016: Stateless mode for multi-user public Nextcloud instances
#
# This image excludes:
# - Vector database dependencies (qdrant-client)
# - Background sync workers
# - Admin UI routes (/app)
# - Semantic search tools
#
# Features included:
# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
# 2. sqlite for development with token db
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
git
# Copy project files
COPY . .
RUN uv sync --locked --no-dev --no-editable --no-cache
# Set Smithery mode environment variables
ENV SMITHERY_DEPLOYMENT=true
ENV VECTOR_SYNC_ENABLED=false
# Smithery sets PORT=8081 by default
EXPOSE 8081
# Health check endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()"
CMD ["/app/.venv/bin/smithery-main"]
+56 -9
View File
@@ -1,6 +1,11 @@
<p align="center">
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
</p>
# Nextcloud MCP Server
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
[![smithery badge](https://smithery.ai/badge/@cbcoutinho/nextcloud-mcp-server)](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
@@ -13,7 +18,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
## Quick Start
Get up and running in 60 seconds using Docker:
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
2. Click "Deploy" and configure:
- **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`)
- **Username**: Your Nextcloud username
- **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions
> [!NOTE]
> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md).
## Docker (Self-Hosted)
For full features including semantic search, run with Docker:
```bash
# 1. Create a minimal configuration
@@ -29,10 +47,24 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
# 3. Test the connection
curl http://127.0.0.1:8000/health/ready
# 4. Connect to the endpoint
http://127.0.0.1:8000/sse
# Or with --transport streamable-http
http://127.0.0.1:8000/mcp
```
**Docker Compose Profiles** (for development/testing):
```bash
docker compose --profile single-user up -d # Port 8000
docker compose --profile multi-user-basic up -d # Port 8003
docker compose --profile oauth up -d # Port 8001
docker compose --profile login-flow up -d # Port 8004
```
**Next Steps:**
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
@@ -40,7 +72,7 @@ curl http://127.0.0.1:8000/health/ready
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
@@ -58,7 +90,7 @@ curl http://127.0.0.1:8000/health/ready
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
| **Tables** | 5 | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | Create and manage shares |
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -76,19 +108,33 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
### Authentication Modes
The server supports two authentication modes:
The server supports four authentication modes:
**Single-User Mode (BasicAuth):**
**Single-User (BasicAuth):**
- One set of credentials shared by all MCP clients
- Simple setup: username + app password in environment variables
- All clients access Nextcloud as the same user
- Best for: Personal use, development, single-user deployments
**Multi-User Mode (OAuth):**
**Multi-User (BasicAuth Pass-Through):**
- MCP clients send credentials via Authorization header
- Server passes through to Nextcloud (stateless by default)
- Optional offline access for background operations (`ENABLE_MULTI_USER_BASIC_AUTH=true`)
- Best for: Multi-user setups without OAuth infrastructure
**Multi-User (OAuth):**
- Each MCP client authenticates separately with their own Nextcloud account
- Per-user scopes and permissions (clients only see tools they're authorized for)
- More secure: tokens expire, credentials never shared with server
- Best for: Teams, multi-user deployments, production environments with multiple users
- Requires: Patches to the `user_oidc` app (experimental)
**Multi-User (Login Flow v2):**
- Uses Nextcloud's native Login Flow v2 to obtain per-user app passwords
- No OAuth patches required — works with stock Nextcloud
- Each user authenticates via browser, server manages app passwords
- Best for: Multi-user deployments without OAuth infrastructure (`ENABLE_LOGIN_FLOW=true`)
- Experimental: See [ADR-022](docs/ADR-022-deployment-mode-consolidation.md) for details
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
@@ -104,7 +150,7 @@ This enables natural language queries and helps discover related content across
> [!NOTE]
> **Semantic Search is experimental and opt-in:**
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Disabled by default (`ENABLE_SEMANTIC_SEARCH=false`)
> - Currently supports Notes app only (multi-app support planned)
> - Requires additional infrastructure: vector database + embedding service
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
@@ -122,7 +168,8 @@ This enables natural language queries and helps discover related content across
### Features
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
### Advanced Topics
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works (experimental)
+90
View File
@@ -0,0 +1,90 @@
# Alembic configuration file for nextcloud-mcp-server
[alembic]
# Path to migration scripts
script_location = nextcloud_mcp_server/alembic
# Template used to generate migration file names
# Default: %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Timezone for migration timestamps
# Default: utc
timezone = utc
# Max length of characters to apply to the "slug" field
# Default: 40
# truncate_slug_length = 40
# Set to 'true' to run the environment during the 'revision' command
# Default: false
# revision_environment = false
# Set to 'true' to allow .pyc and .pyo files without a source .py file
# Default: false
# sourceless = false
# Version location specification
# Supports single or multiple directories
version_locations = nextcloud_mcp_server/alembic/versions
# Path separator for version locations (required to suppress deprecation warning)
# Use os (for cross-platform compatibility)
path_separator = os
# Set to 'true' to search source files recursively in each "version_locations" directory
# Default: false
# recursive_version_locations = false
# Output encoding used when revision files are written
# Default: utf-8
# output_encoding = utf-8
# Database URL - can be overridden by:
# 1. Passing -x database_url=... to alembic commands
# 2. Setting in environment via get_database_url() in env.py
# Default: sqlite:///app/data/tokens.db
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
[post_write_hooks]
# Post-write hooks allow you to run scripts after generating migration files
# Example: format migrations with ruff
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = format REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+71
View File
@@ -0,0 +1,71 @@
Database Migrations for nextcloud-mcp-server
============================================
This directory contains Alembic database migrations for the token storage database.
Structure
---------
- env.py: Alembic environment configuration
- script.py.mako: Template for generating new migration files
- versions/: Directory containing migration scripts
Usage
-----
Migrations are managed via the CLI:
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show current database version
uv run nextcloud-mcp-server db current
# Show migration history
uv run nextcloud-mcp-server db history
# Create a new migration (developers only)
uv run nextcloud-mcp-server db migrate "description of changes"
# Downgrade database by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
Direct Alembic Usage
--------------------
You can also use Alembic commands directly:
# Specify database URL via -x flag
uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head
# Or set in alembic.ini and run
uv run alembic upgrade head
uv run alembic current
uv run alembic history
Writing Migrations
------------------
Since we don't use SQLAlchemy models, migrations are written with raw SQL:
def upgrade() -> None:
op.execute("""
ALTER TABLE refresh_tokens
ADD COLUMN new_field TEXT
""")
def downgrade() -> None:
# SQLite doesn't support DROP COLUMN, use table recreation
op.execute("""
CREATE TABLE refresh_tokens_new AS
SELECT user_id, encrypted_token, ... FROM refresh_tokens
""")
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
Migration File Naming
---------------------
Format: YYYYMMDD_HHMM_<revision>_<slug>.py
Example: 20251217_2200_001_initial_schema.py
Notes
-----
- Migrations run automatically when RefreshTokenStorage.initialize() is called
- Existing databases are automatically stamped with the initial version
- SQLite has limited ALTER TABLE support - complex changes require table recreation
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""Apply migration changes to upgrade the database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Revert migration changes to downgrade the database schema."""
${downgrades if downgrades else "pass"}
@@ -3,3 +3,9 @@
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
# Set overwrite.cli.url to the external URL for OIDC discovery
# This ensures OAuth flows redirect to the correct external URL
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
# which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80)
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
@@ -0,0 +1,6 @@
#!/bin/bash
set -euox pipefail
echo "Disabling bruteforce protection and rate limiting for dev/CI..."
php /var/www/html/occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean
php /var/www/html/occ config:system:set ratelimit.protection.enabled --value=false --type=boolean
echo "Bruteforce protection and rate limiting disabled."
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable news
@@ -2,4 +2,30 @@
set -euox pipefail
php /var/www/html/occ app:enable notes
echo "Installing and configuring notes app for testing..."
# Check if development notes app is mounted at /opt/apps/notes
if [ -d /opt/apps/notes ]; then
echo "Development notes app found at /opt/apps/notes"
# Remove any existing notes app in apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/notes ]; then
echo "Removing existing notes in apps..."
rm -rf /var/www/html/custom_apps/notes
fi
# Create symlink from apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/notes -> /opt/apps/notes"
ln -sf /opt/apps/notes /var/www/html/custom_apps/notes
echo "Enabling notes app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable notes
elif [ -d /var/www/html/custom_apps/notes ]; then
echo "notes app directory found in apps (already installed)"
php /var/www/html/occ app:enable notes
else
echo "notes app not found, installing from app store..."
php /var/www/html/occ app:install notes
php /var/www/html/occ app:enable notes
fi
@@ -13,6 +13,14 @@ echo "===================================================================="
echo "Configuring user_oidc provider for Keycloak..."
echo "===================================================================="
# Quick check: Is keycloak service in the Docker network?
# When the keycloak profile is not active, this hostname won't resolve.
if ! getent hosts keycloak >/dev/null 2>&1; then
echo " Keycloak service not detected in Docker network (profile not active)"
echo " Skipping keycloak provider configuration"
exit 0
fi
# Wait for Keycloak to be ready and realm to be available
echo "Waiting for Keycloak realm to be available..."
MAX_RETRIES=30
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -euox pipefail
echo "Installing Astrolabe app..."
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
if [ -d /opt/apps/astrolabe ]; then
echo "Development astrolabe app found at /opt/apps/astrolabe"
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/astrolabe ]; then
echo "Removing existing astrolabe in custom_apps..."
rm -rf /var/www/html/custom_apps/astrolabe
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable astrolabe
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
echo "astrolabe app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable astrolabe
else
echo "astrolabe app not found, installing from app store..."
php /var/www/html/occ app:install astrolabe
php /var/www/html/occ app:enable astrolabe
fi
echo "Astrolabe app installed successfully"
echo ""
echo "Note: MCP server configuration is managed dynamically during tests"
echo " to support testing multiple MCP server deployments."
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# Configure MCP server URL for Astrolabe background sync
# This URL is used by Astrolabe to send app passwords to the MCP server
set -e
if [ -z "${MCP_SERVER_URL:-}" ]; then
echo "MCP_SERVER_URL not set, skipping Astrolabe MCP server URL configuration"
exit 0
fi
echo "Configuring MCP server URL: $MCP_SERVER_URL"
# Set the mcp_server_url in config.php via occ
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
echo "MCP server URL configured successfully"
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="80" ry="80" fill="#0082C9"/>
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

+25
View File
@@ -0,0 +1,25 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.58.3"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
major_version_zero = true
# Update chart version only (NOT appVersion)
version_files = [
"Chart.yaml:^version:"
]
# Ignore tags from other components
ignored_tag_formats = [
"v*", # MCP server tags
"astrolabe-v*", # Astrolabe tags
]
# Filter commits by scope
# Includes helm-scoped commits AND MCP server version bumps (which update appVersion)
[tool.commitizen.customize]
changelog_pattern = "^((feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:|bump: version.*→.*)"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
message_template = "{{change_type}}(helm): {{message}}"
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.15.5
version: 1.17.0
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.34.0
digest: sha256:d51c97d05be2614b751c0dd7267ef7dc959eff5ebef859c5f895c5c554b7a874
generated: "2025-11-09T17:08:02.86648061Z"
version: 1.47.0
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
generated: "2026-03-02T11:15:27.688786078Z"
+13 -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.38.0
appVersion: "0.38.0"
version: 0.58.3
appVersion: "0.65.0"
keywords:
- nextcloud
- mcp
@@ -25,12 +25,21 @@ annotations:
# Grafana dashboard support
grafana_dashboard: "true"
grafana_dashboard_folder: "Nextcloud MCP"
artifacthub.io/changes: |
- kind: added
description: Login Flow v2 auth mode for Helm chart (ADR-022)
- kind: added
description: Multi-user BasicAuth guidance in post-install NOTES
- kind: added
description: Version and changelog info in post-install NOTES
- kind: changed
description: Updated appVersion to 0.64.4
dependencies:
- name: qdrant
version: "1.15.5"
version: "1.17.0"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.34.0"
version: "1.47.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+35 -14
View File
@@ -99,11 +99,11 @@ ingress:
|-----------|-------------|---------|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
| `nextcloud.publicIssuerUrl` | Public URL for browser-accessible OAuth authorization endpoint (OAuth only, optional) | Smart default** |
**Smart Defaults:**
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
- `**publicIssuerUrl`: If not set, defaults to `nextcloud.host`. **Only used for authorization endpoints** that browsers must access. All server-to-server endpoints (token, JWKS, introspection, userinfo) use URLs from OIDC discovery without rewriting
#### Authentication
@@ -118,6 +118,25 @@ ingress:
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
#### Data Storage
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
| `dataStorage.existingClaim` | Use existing PVC | `""` |
**When to enable persistence:**
- Multi-user basic auth with offline access (stores `tokens.db`)
- Qdrant persistent mode (stores vector database)
- Any feature requiring persistent app data
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
#### MCP Server Configuration
| Parameter | Description | Default |
@@ -208,16 +227,16 @@ The application exposes HTTP health check endpoints:
#### Vector Search & Semantic Capabilities (Optional)
Enable semantic search capabilities by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
Enable semantic search capabilities with BM25 hybrid search by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
**Vector Sync Configuration:**
**Semantic Search Configuration:**
| Parameter | Description | Default |
|-----------|-------------|---------|
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
| `semanticSearch.queueMaxSize` | Maximum queue size for pending documents | `10000` |
**Document Chunking Configuration:**
@@ -427,7 +446,7 @@ nextcloud:
host: https://cloud.example.com
# mcpServerUrl and publicIssuerUrl are optional!
# If not set, mcpServerUrl defaults to ingress host or localhost
# publicIssuerUrl defaults to nextcloud.host
# publicIssuerUrl defaults to nextcloud.host (only used for browser-accessible auth endpoint)
auth:
mode: oauth
@@ -459,7 +478,7 @@ This example shows OAuth without pre-registered credentials (using DCR) and opti
nextcloud:
host: https://cloud.example.com
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
# publicIssuerUrl will automatically default to nextcloud.host
# publicIssuerUrl will automatically default to nextcloud.host (only used for browser-accessible auth endpoint)
auth:
mode: oauth
@@ -537,8 +556,8 @@ auth:
username: admin
password: secure-password
# Enable vector sync
vectorSync:
# Enable semantic search
semanticSearch:
enabled: true
scanInterval: 1800 # Scan every 30 minutes
processorWorkers: 5
@@ -576,7 +595,7 @@ ollama:
Or use an external Ollama instance:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -592,7 +611,7 @@ ollama:
Or use OpenAI for embeddings:
```yaml
vectorSync:
semanticSearch:
enabled: true
qdrant:
@@ -689,7 +708,9 @@ Readiness (returns 200 if ready, 503 if not ready):
1. **Connection refused to Nextcloud**
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
- For OAuth mode: Ensure MCP server can reach OIDC discovery endpoints (token, JWKS, introspection, userinfo URLs)
- Check network policies and firewall rules
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
2. **Authentication failures**
- For basic auth: verify username/password are correct
@@ -57,6 +57,28 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
{{- else if eq .Values.auth.mode "multi-user-basic" }}
3. Multi-User BasicAuth Mode (Pass-Through):
- Users provide credentials via Authorization header
- Connected to: {{ .Values.nextcloud.host }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
- Offline access: Enabled (background operations with app passwords)
- Token storage: {{ .Values.auth.multiUserBasic.tokenStorageDb }}
{{- else }}
- Offline access: Disabled (stateless pass-through)
{{- end }}
{{- else if eq .Values.auth.mode "login-flow" }}
3. Login Flow v2 Mode (Experimental, ADR-022):
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
- Connected to: {{ .Values.nextcloud.host }}
- Token storage: {{ .Values.auth.loginFlow.tokenStorageDb }}
Users authenticate via Nextcloud's native Login Flow v2 — no OAuth patches required.
Each user gets a per-device app password managed by the MCP server.
IMPORTANT: Login Flow v2 is experimental. See ADR-022 for details.
{{- end }}
{{- if .Values.documentProcessing.enabled }}
@@ -69,12 +91,12 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
{{- end }}
{{- end }}
{{- if .Values.vectorSync.enabled }}
{{- if .Values.semanticSearch.enabled }}
5. Vector Search & Semantic Capabilities:
- Vector Sync: Enabled
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
5. Semantic Search & Vector Capabilities:
- Semantic Search: Enabled
- Scan Interval: {{ .Values.semanticSearch.scanInterval }}s
- Processor Workers: {{ .Values.semanticSearch.processorWorkers }}
{{- if .Values.qdrant.enabled }}
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
{{- else }}
@@ -120,6 +142,61 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
{{- end }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- if or $legacyMultiUserBasic $legacyQdrant }}
================================================================================
DEPRECATION WARNING
================================================================================
You are using deprecated persistence configuration that will be removed in a
future release. Your deployment will continue to work, but please migrate to
the new unified dataStorage configuration.
Deprecated settings detected:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.* (currently enabled)
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.* (currently enabled)
{{- end }}
To migrate, update your values.yaml:
dataStorage:
enabled: true
{{- if $legacyMultiUserBasic }}
size: {{ .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
size: {{ .Values.qdrant.localPersistence.size }}
{{- end }}
# storageClass: "" # Optional: specify storage class
# existingClaim: "" # Optional: use existing PVC to preserve data
After migrating, remove the deprecated settings:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.enabled
- auth.multiUserBasic.persistence.size
- auth.multiUserBasic.persistence.storageClass
- auth.multiUserBasic.persistence.accessMode
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.enabled
- qdrant.localPersistence.size
- qdrant.localPersistence.storageClass
- qdrant.localPersistence.accessMode
{{- end }}
================================================================================
{{- end }}
Deployed version:
- Chart: {{ .Chart.Version }}
- App: {{ .Chart.AppVersion }}
Full changelog: https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/charts/nextcloud-mcp-server/CHANGELOG.md
For more information and documentation:
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
@@ -72,6 +72,28 @@ Create the name of the secret to use for basic auth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for multi-user basic auth
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicSecretName" -}}
{{- if .Values.auth.multiUserBasic.existingSecret }}
{{- .Values.auth.multiUserBasic.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for multi-user basic token storage
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicPvcName" -}}
{{- if .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-token-storage
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for OAuth
*/}}
@@ -83,6 +105,17 @@ Create the name of the secret to use for OAuth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for Login Flow v2
*/}}
{{- define "nextcloud-mcp-server.loginFlowSecretName" -}}
{{- if .Values.auth.loginFlow.existingSecret }}
{{- .Values.auth.loginFlow.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-login-flow
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for OAuth storage
*/}}
@@ -105,6 +138,57 @@ Create the name of the PVC to use for Qdrant local persistent storage
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for /app/data storage
*/}}
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
{{- if .Values.dataStorage.existingClaim }}
{{- .Values.dataStorage.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
{{- end }}
{{- end }}
{{/*
Determine if data storage PVC should be enabled (backward compatible)
Checks new dataStorage.enabled OR legacy persistence configs
*/}}
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
{{- if .Values.dataStorage.enabled -}}
true
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
true
{{- else if eq .Values.auth.mode "login-flow" -}}
true
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy multi-user-basic persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy qdrant persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Return the MCP server port
*/}}
@@ -46,8 +46,10 @@ spec:
args:
- "--transport"
- "{{ .Values.mcp.transport }}"
{{- if eq .Values.auth.mode "oauth" }}
{{- if or (eq .Values.auth.mode "oauth") (eq .Values.auth.mode "login-flow") }}
- "--oauth"
{{- end }}
{{- if eq .Values.auth.mode "oauth" }}
- "--oauth-token-type"
- "{{ .Values.auth.oauth.tokenType }}"
{{- end }}
@@ -68,7 +70,7 @@ spec:
- name: NEXTCLOUD_HOST
value: {{ .Values.nextcloud.host | quote }}
{{- if eq .Values.auth.mode "basic" }}
# Basic auth mode
# Basic auth mode (single-user)
- name: NEXTCLOUD_USERNAME
valueFrom:
secretKeyRef:
@@ -79,6 +81,41 @@ spec:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
key: {{ .Values.auth.basic.passwordKey }}
{{- else if eq .Values.auth.mode "multi-user-basic" }}
# Multi-user BasicAuth mode (pass-through)
- name: ENABLE_MULTI_USER_BASIC_AUTH
value: "true"
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
# Background operations with app passwords (replaces deprecated ENABLE_OFFLINE_ACCESS)
- name: ENABLE_BACKGROUND_OPERATIONS
value: "true"
- name: TOKEN_STORAGE_DB
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
- name: TOKEN_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
{{- if or .Values.auth.multiUserBasic.clientId .Values.auth.multiUserBasic.existingSecret }}
# Static OAuth credentials (optional - uses DCR if not provided)
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientIdKey }}
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientSecretKey }}
{{- end }}
{{- end }}
{{- else if eq .Values.auth.mode "oauth" }}
# OAuth mode
- name: NEXTCLOUD_MCP_SERVER_URL
@@ -87,7 +124,7 @@ spec:
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.oauth.scopes | quote }}
{{- if .Values.auth.oauth.clientId }}
{{- if or .Values.auth.oauth.clientId .Values.auth.oauth.existingSecret }}
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
@@ -99,6 +136,21 @@ spec:
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
key: {{ .Values.auth.oauth.clientSecretKey }}
{{- end }}
{{- else if eq .Values.auth.mode "login-flow" }}
# Login Flow v2 mode (ADR-022)
- name: ENABLE_LOGIN_FLOW
value: "true"
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: TOKEN_STORAGE_DB
value: {{ .Values.auth.loginFlow.tokenStorageDb | quote }}
- name: TOKEN_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.loginFlowSecretName" . }}
key: {{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}
{{- end }}
{{- if .Values.documentProcessing.enabled }}
# Document processing
@@ -147,16 +199,16 @@ spec:
value: {{ .Values.documentProcessing.custom.types | quote }}
{{- end }}
{{- end }}
# Vector Sync
- name: VECTOR_SYNC_ENABLED
value: {{ .Values.vectorSync.enabled | quote }}
{{- if .Values.vectorSync.enabled }}
# Semantic Search (replaces deprecated VECTOR_SYNC_ENABLED)
- name: ENABLE_SEMANTIC_SEARCH
value: {{ .Values.semanticSearch.enabled | quote }}
{{- if .Values.semanticSearch.enabled }}
- name: VECTOR_SYNC_SCAN_INTERVAL
value: {{ .Values.vectorSync.scanInterval | quote }}
value: {{ .Values.semanticSearch.scanInterval | quote }}
- name: VECTOR_SYNC_PROCESSOR_WORKERS
value: {{ .Values.vectorSync.processorWorkers | quote }}
value: {{ .Values.semanticSearch.processorWorkers | quote }}
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
value: {{ .Values.vectorSync.queueMaxSize | quote }}
value: {{ .Values.semanticSearch.queueMaxSize | quote }}
{{- end }}
# Document Chunking (always set, used by vector sync processor)
- name: DOCUMENT_CHUNK_SIZE
@@ -247,29 +299,29 @@ spec:
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
- name: oauth-storage
mountPath: /app/.oauth
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
- name: data-storage
mountPath: /app/data
{{- end }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
- name: oauth-storage
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
- name: data-storage
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
{{- else }}
emptyDir: {}
{{- end }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
+35 -6
View File
@@ -16,20 +16,49 @@ spec:
storage: {{ .Values.auth.oauth.persistence.size }}
{{- end }}
---
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
{{- if eq .Values.auth.mode "login-flow" }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.qdrant.localPersistence.accessMode }}
{{- if .Values.qdrant.localPersistence.storageClass }}
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
- ReadWriteOnce
resources:
requests:
storage: 100Mi
{{- end }}
---
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- $accessMode := .Values.dataStorage.accessMode }}
{{- $storageClass := .Values.dataStorage.storageClass }}
{{- $size := .Values.dataStorage.size }}
{{- if $legacyMultiUserBasic }}
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
{{- $size = .Values.qdrant.localPersistence.size }}
{{- end }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ $accessMode }}
{{- if $storageClass }}
storageClassName: {{ $storageClass }}
{{- end }}
resources:
requests:
storage: {{ .Values.qdrant.localPersistence.size }}
storage: {{ $size }}
{{- end }}
@@ -13,6 +13,24 @@ data:
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "multi-user-basic" }}
{{- if and .Values.auth.multiUserBasic.enableOfflineAccess (not .Values.auth.multiUserBasic.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}: {{ .Values.auth.multiUserBasic.tokenEncryptionKey | b64enc | quote }}
{{- if .Values.auth.multiUserBasic.clientId }}
{{ .Values.auth.multiUserBasic.clientIdKey }}: {{ .Values.auth.multiUserBasic.clientId | b64enc | quote }}
{{ .Values.auth.multiUserBasic.clientSecretKey }}: {{ .Values.auth.multiUserBasic.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "oauth" }}
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
apiVersion: v1
@@ -27,3 +45,17 @@ data:
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "login-flow" }}
{{- if not .Values.auth.loginFlow.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-login-flow
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}: {{ .Values.auth.loginFlow.tokenEncryptionKey | b64enc | quote }}
{{- end }}
{{- end }}
+100 -12
View File
@@ -26,21 +26,30 @@ nextcloud:
# Example: https://mcp.example.com
mcpServerUrl: ""
# Public issuer URL for OAuth (OAuth mode only)
# If not specified, defaults to nextcloud.host
# Only set this if your Nextcloud is accessible at a different URL for OAuth
# Public issuer URL for browser-accessible OAuth authorization endpoints (OAuth mode only)
# ONLY used to make authorization endpoints accessible to users' browsers
# All server-to-server communication (token endpoint, JWKS, introspection, userinfo)
# uses URLs from OIDC discovery without any rewriting
#
# Use case: When MCP server accesses Nextcloud at one URL but browsers need a different
# public URL for OAuth login (e.g., server uses internal DNS, browsers use public domain)
#
# If not specified, defaults to nextcloud.host (works when MCP server and browsers
# both access Nextcloud at the same URL)
# Example: https://cloud.example.com
publicIssuerUrl: ""
# Authentication configuration
# Choose either basic auth OR oauth (not both)
# Choose one mode: "basic", "multi-user-basic", "oauth", or "login-flow"
auth:
# Authentication mode: "basic" or "oauth"
# basic: Uses username/password (recommended for most users)
# Authentication mode: "basic", "multi-user-basic", "oauth", or "login-flow"
# basic: Single-user with username/password (recommended for personal use)
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
# login-flow: Multi-user via Nextcloud Login Flow v2 (experimental, ADR-022)
mode: basic
# Basic authentication settings
# Basic authentication settings (single-user mode)
basic:
# Nextcloud username (ignored if existingSecret is set)
username: ""
@@ -58,6 +67,47 @@ auth:
usernameKey: "username"
passwordKey: "password"
# Multi-user BasicAuth settings (pass-through mode)
# Users provide credentials in request headers (Authorization: Basic ...)
# Server optionally stores app passwords for background operations
multiUserBasic:
# Enable offline access (background operations using app passwords via Astrolabe)
# When enabled, requires token encryption key. OAuth client credentials are optional (uses DCR if not provided)
enableOfflineAccess: false
# Token encryption key (required if enableOfflineAccess: true, ignored if existingSecret is set)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
tokenEncryptionKey: ""
# Token storage database path
tokenStorageDb: "/app/data/tokens.db"
# OAuth client credentials (optional - uses Dynamic Client Registration if not provided)
# Only needed if enableOfflineAccess: true
clientId: ""
clientSecret: ""
# OAuth scopes to request (space-separated)
scopes: "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
# Use existing secret for multi-user basic auth credentials
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
# Secret should contain keys specified in the *Key fields below
# Example:
# kubectl create secret generic my-multiuser-creds \
# --from-literal=token_encryption_key=ESF1BvEQ... \
# --from-literal=client_id=my-client-id \
# --from-literal=client_secret=my-client-secret
existingSecret: ""
# Keys in the existing secret
tokenEncryptionKeyKey: "token_encryption_key"
clientIdKey: "client_id"
clientSecretKey: "client_secret"
# Persistent storage for token database
persistence:
enabled: true
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
size: 100Mi
# Use existing PVC
existingClaim: ""
# OAuth2/OIDC settings (experimental)
oauth:
# OAuth token type: "jwt" or "opaque"
@@ -90,6 +140,43 @@ auth:
# Use existing PVC
existingClaim: ""
# Login Flow v2 settings (experimental, ADR-022)
# Uses Nextcloud's native Login Flow v2 to obtain app passwords per user.
# No OAuth patches required — works with stock Nextcloud.
# See: docs/ADR-022-deployment-mode-consolidation.md
loginFlow:
# Token encryption key (required, ignored if existingSecret is set)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
tokenEncryptionKey: ""
# Token storage database path
tokenStorageDb: "/app/data/tokens.db"
# Use existing secret instead of creating one
existingSecret: ""
# Key in the existing secret
tokenEncryptionKeyKey: "token_encryption_key"
# Data Storage Configuration
# Persistent volume for /app/data directory
# Used for: token databases, qdrant persistent storage, and any app data
# When disabled, uses emptyDir (non-persistent, but still writable)
dataStorage:
# Enable persistent storage for /app/data
# Set to true when using:
# - Multi-user basic auth with offline access (stores tokens.db)
# - Login flow mode (stores app passwords in tokens.db)
# - Qdrant persistent mode (stores vector database)
# - Any feature requiring persistent app data
# Set to false for basic auth without persistence (uses emptyDir)
enabled: false
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
# Size for data storage (should accommodate tokens.db and/or qdrant data)
# Recommended: 1Gi minimum, 5Gi for production with qdrant
size: 1Gi
# Use existing PVC
existingClaim: ""
# MCP server configuration
mcp:
# Transport mode (default: streamable-http for SSE)
@@ -316,10 +403,11 @@ extraEnvFrom: []
# - secretRef:
# name: my-secret
# Vector Sync Configuration
# Background synchronization of Nextcloud content into vector database for semantic search
vectorSync:
# Enable background vector synchronization
# Semantic Search Configuration
# Enable semantic search with BM25 hybrid search and background synchronization
# of Nextcloud content into vector database
semanticSearch:
# Enable semantic search and background vector synchronization
enabled: false
# Scan interval in seconds (how often to check for changes)
scanInterval: 3600
@@ -330,7 +418,7 @@ vectorSync:
# Document Chunking Configuration
# Controls how documents are split into chunks before embedding
# Only relevant when vectorSync.enabled is true
# Only relevant when semanticSearch.enabled is true
documentChunking:
# Number of words per chunk (default: 512)
# Smaller chunks (256-384): Better for precise searches, more chunks to store
+25
View File
@@ -0,0 +1,25 @@
# CI-specific overrides for RAG evaluation pipeline
# This file is used by the rag-evaluation.yml workflow to configure the MCP
# container with OpenAI/GitHub Models API for vector embeddings.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
#
# Environment variables (set in CI workflow):
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
services:
mcp:
environment:
# OpenAI provider configuration (required for CI vector sync)
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
# Faster sync for CI
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
# Enable document processing for PDF parsing
- ENABLE_DOCUMENT_PROCESSING=true
+137 -14
View File
@@ -3,11 +3,13 @@ services:
# https://hub.docker.com/_/mariadb
db:
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
image: docker.io/library/mariadb:lts@sha256:6b848cb24fbbd87429917f6c4422ac53c343e85692eb0fef86553e99e4f422f3
image: docker.io/library/mariadb:lts@sha256:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
- db:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
environment:
- MYSQL_ROOT_PASSWORD=password
- MYSQL_PASSWORD=password
@@ -17,24 +19,25 @@ 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:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
restart: always
app:
image: docker.io/library/nextcloud:32.0.1@sha256:5b043f7ea2f609d5ff5635f475c30d303bec17775a5c3f7fa435e3818e669120
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267}
restart: always
ports:
- 0.0.0.0:8080:80
- 127.0.0.1:8080:80
depends_on:
- redis
- db
- keycloak
volumes:
- nextcloud:/var/www/html
- ./app-hooks:/docker-entrypoint-hooks.d:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
#- ./third_party/oidc:/opt/apps/oidc:ro # Use app store version; dev mount lacks vendor/
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -44,6 +47,7 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
- MCP_SERVER_URL=${MCP_SERVER_URL:-}
healthcheck:
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
interval: 10s
@@ -51,14 +55,14 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -85,8 +89,8 @@ services:
- NEXTCLOUD_PASSWORD=admin
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
# Semantic search configuration (ADR-007, ADR-021)
#- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -121,6 +125,46 @@ services:
# Tune these based on your embedding model and content type
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
profiles:
- single-user
mcp-multi-user-basic:
build: .
restart: always
command: ["--transport", "streamable-http"]
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8003:8000
environment:
# Multi-user BasicAuth pass-through mode (ADR-020)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- ENABLE_MULTI_USER_BASIC_AUTH=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=fqqI4G51yBCOcu9cvv6wCUJB7sf_CK2za5ClC6b86yY=
- TOKEN_STORAGE_DB=/app/data/tokens.db
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# OAuth credentials for background sync (optional - uses DCR if not provided)
# Uncomment to avoid DCR:
# - NEXTCLOUD_OIDC_CLIENT_ID=your_client_id
# - NEXTCLOUD_OIDC_CLIENT_SECRET=your_client_secret
# NO admin credentials - credentials come from client Authorization header
volumes:
- multi-user-basic-data:/app/data
profiles:
- multi-user-basic
mcp-oauth:
build: .
@@ -142,23 +186,38 @@ services:
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# Refresh token storage (ADR-002 Tier 1)
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# Qdrant configuration - persistent local storage
- QDRANT_LOCATION=/app/data/qdrant
# Embedding provider for vector sync (use Simple provider as fallback)
# Ollama not available in CI/test environments
# - OLLAMA_BASE_URL=http://ollama:11434
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# JWT token type is used for testing (faster validation, scopes embedded in token)
volumes:
- oauth-client-storage:/app/.oauth
- oauth-tokens:/app/data
profiles:
- oauth
keycloak:
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
command:
- "start-dev"
- "--import-realm"
@@ -178,6 +237,8 @@ services:
interval: 10s
timeout: 5s
retries: 30
profiles:
- keycloak
mcp-keycloak:
build: .
@@ -206,7 +267,7 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
- ENABLE_OFFLINE_ACCESS=true
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -223,9 +284,68 @@ services:
volumes:
- keycloak-tokens:/app/data
- keycloak-oauth-storage:/app/.oauth
profiles:
- keycloak
# Login Flow v2 mode (ADR-022)
# Test with: docker compose --profile login-flow up --build -d
mcp-login-flow:
build: .
restart: always
# --oauth enables the OAuth/OIDC identity layer that Login Flow v2 builds on
# (user identity via OAuth session, Nextcloud access via app passwords)
command: ["--transport", "streamable-http", "--oauth", "--port", "8004"]
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8004:8004
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Login Flow v2 (ADR-022)
- ENABLE_LOGIN_FLOW=true
# Token storage (required for app password + session persistence)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=rxJvkBf7ZBjZZDL4a1sSqjhmjawhmbRMSOGfK8HDyKU=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
volumes:
- login-flow-data:/app/data
- login-flow-oauth-storage:/app/.oauth
profiles:
- login-flow
# Smithery stateless deployment mode (ADR-016)
# Test with: docker compose --profile smithery up smithery
# Then: curl http://localhost:8081/.well-known/mcp-config
smithery:
build:
context: .
dockerfile: Dockerfile.smithery
restart: always
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8081:8081
environment:
- SMITHERY_DEPLOYMENT=true
- ENABLE_SEMANTIC_SEARCH=false
- PORT=8081
profiles:
- smithery
qdrant:
image: qdrant/qdrant:v1.15.5@sha256:0fb8897412abc81d1c0430a899b9a81eb8328aa634e7242d1bc804c1fe8fe863
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
@@ -249,5 +369,8 @@ volumes:
oauth-tokens:
keycloak-tokens:
keycloak-oauth-storage:
login-flow-data:
login-flow-oauth-storage:
qdrant-data:
mcp-data:
multi-user-basic-data:
@@ -1,7 +1,8 @@
# ADR-011: Improving Semantic Search Quality Through Better Chunking and Embeddings
**Status**: Proposed
**Status**: Partially Implemented (Chunking Complete, Embeddings Pending)
**Date**: 2025-11-12
**Implementation Date**: 2025-11-18 (Chunking)
**Authors**: Development Team
**Related**: ADR-003 (Vector Database Architecture), ADR-008 (MCP Sampling for RAG)
@@ -893,3 +894,50 @@ This ADR addresses the root causes of poor semantic search recall:
- No new infrastructure or ongoing costs
**Next Steps**: Approve ADR → Implement changes → Reindex → Validate → Production rollout
## Implementation Status
### Completed (2025-11-18)
**✅ Semantic Markdown-Aware Chunking (Option C1 + C3 Hybrid)**
Implementation details:
- Replaced custom word-based chunking with `MarkdownTextSplitter` from LangChain
- Optimized for Nextcloud Notes markdown content with special handling for:
- Headers (`#`, `##`, `###`, etc.)
- Code blocks (` ``` `)
- Lists (`-`, `*`, `1.`)
- Horizontal rules (`---`)
- Paragraphs and sentences
- Maintained `ChunkWithPosition` interface for backward compatibility
- Updated configuration defaults:
- `DOCUMENT_CHUNK_SIZE`: 512 words → 2048 characters
- `DOCUMENT_CHUNK_OVERLAP`: 50 words → 200 characters
- Updated unit tests to verify position tracking and boundary preservation
- All tests passing with markdown-aware character-based chunking
**Files Modified**:
- `nextcloud_mcp_server/vector/document_chunker.py` - LangChain integration
- `nextcloud_mcp_server/config.py` - Character-based defaults
- `tests/unit/test_document_chunker.py` - Updated test suite
**Dependencies Added**:
- `langchain-text-splitters>=1.0.0` (already present in `pyproject.toml`)
**Migration Required**:
- ⚠️ Full reindex required to apply new chunking strategy
- Existing documents in vector database use old word-based chunks
- See "Migration Strategy" section above for reindexing process
### Pending
**⏳ Embedding Model Upgrade (Option E1)**
Still to be implemented:
- Switch from `nomic-embed-text` (768-dim) to `mxbai-embed-large-v1` (1024-dim)
- Implement dynamic dimension detection in `ollama_provider.py`
- Create migration script for collection reindexing
- Run benchmarking to validate improvement
- Deploy to production with atomic collection swap
**Estimated Timeline**: 1-2 weeks for implementation and validation
+241
View File
@@ -0,0 +1,241 @@
# ADR-014: Replace Custom Keyword Search with BM25 Hybrid Search via Qdrant
**Date:** 2025-11-16
**Status:** Implemented
---
### 1. Context
Our RAG application currently employs two separate retrieval mechanisms:
1. **Dense (Semantic) Search:** Using vector embeddings stored in our Qdrant database to find semantically similar context.
2. **Keyword Search:** A custom-built fuzzy/character-based search to match-specific keywords, acronyms, and product codes that semantic search often misses.
This dual-system approach has several drawbacks:
* **Poor Relevance:** Our current keyword search is basic (e.g., `LIKE` queries or simple fuzzy matching). It is not as effective as modern full-text search algorithms like BM25.
* **Clunky Fusion:** We lack a robust, principled method to combine the results from the two systems. This leads to disjointed logic in the application layer and suboptimal context being passed to the LLM.
* **Architectural Complexity:** We must maintain two separate search pathways (one to Qdrant, one to the keyword search mechanism), increasing code complexity and maintenance overhead.
Our vector database, **Qdrant**, natively supports **hybrid search** by combining dense vectors with BM25-based **sparse vectors** in a single collection.
### 2. Decision
We will **deprecate and remove** the existing custom keyword/fuzzy search functionality.
We will **replace it by implementing native hybrid search within Qdrant**. This involves:
1. **Modifying the Qdrant Collection:** Updating our collection to support a named sparse vector index configured for BM25.
2. **Updating the Ingestion Pipeline:** For every document chunk, we will generate and upsert *both*:
* Its **dense vector** (from our existing embedding model).
* Its **sparse vector** (generated using a BM25-compatible model, e.g., `Qdrant/bm25` from `fastembed`).
3. **Refactoring Retrieval Logic:** All retrieval calls will be consolidated into a single Qdrant query using the `query_points` endpoint. This query will use the `prefetch` parameter to execute both dense and sparse searches, and Qdrant's built-in **Reciprocal Rank Fusion (RRF)** to automatically merge the results into a single, relevance-ranked list.
4. **Backfilling:** A one-time migration script will be created to generate and add sparse vectors for all existing documents in the Qdrant collection.
---
### 3. Considered Options
#### Option 1: Native Qdrant Hybrid Search (Chosen)
* Use Qdrant's built-in sparse vector and RRF capabilities.
* **Pros:**
* **Consolidated Architecture:** Manages both dense and sparse indexes in one database.
* **No Data Sync Issues:** Updates are atomic. A single `upsert` updates both representations.
* **Built-in Fusion:** RRF is handled natively and efficiently by the database.
* **Superior Relevance:** Replaces our brittle custom search with the industry-standard BM25.
* **Cons:**
* Requires a one-time data backfill which may be time-consuming.
* Adds a new step (sparse vector generation) to the ingestion pipeline.
#### Option 2: External Full-Text Search (e.g., Elasticsearch)
* Keep Qdrant for dense search and add a separate Elasticsearch/OpenSearch cluster for BM25.
* **Pros:**
* Provides a very powerful, dedicated full-text search engine.
* **Cons:**
* **High Complexity:** Introduces a new, stateful service to deploy, manage, and scale.
* **Data Sync Nightmare:** We would be responsible for ensuring that the document IDs and content in Qdrant and Elasticsearch are always perfectly synchronized. This is a major source of bugs.
* **Manual Fusion:** The application would have to query both systems and perform RRF manually.
#### Option 3: Keep Current System
* Make no changes.
* **Pros:**
* No engineering effort required.
* **Cons:**
* Fails to address the known relevance and architectural problems.
* Our RAG application's performance will remain suboptimal, especially for keyword-sensitive queries.
---
### 4. Rationale
**Option 1 is the clear winner.** It directly solves our primary problem (poor keyword matching) by adopting the industry-standard BM25.
Critically, it achieves this while **simplifying** our overall architecture, not complicating it. By leveraging features already present in our existing database (Qdrant), we avoid the massive operational and synchronization overhead of adding a second search system (Option 2).
This decision consolidates our retrieval logic, eliminates the data consistency problem, and moves the complex fusion logic (RRF) from the application layer into the database, where it can be performed more efficiently.
### 5. Consequences
**New Work:**
* **Ingestion:** The data ingestion pipeline must be updated to add the `fastembed` library (or similar), generate sparse vectors, and upsert them to the new named vector field in Qdrant.
* **Retrieval:** The application's retrieval service must be refactored to use the `query_points` endpoint with `prefetch` and `fusion=models.Fusion.RRF`.
* **Migration:** A one-time backfill script must be written and executed to add sparse vectors for all existing documents.
* **Infrastructure:** The Qdrant collection schema must be updated (or re-created) to add the `sparse_vectors_config`.
**Positive:**
* **Improved Accuracy:** Retrieval will be significantly more accurate, handling both semantic and keyword queries robustly.
* **Simplified Code:** The application's retrieval logic will be cleaner and simpler, with one endpoint instead of two.
* **Reduced Maintenance:** We will remove the custom fuzzy-search code, which is brittle and difficult to maintain.
**Negative:**
* The data backfill process will require careful management to avoid downtime.
* Ingestion time will slightly increase due to the extra step of sparse vector generation. This is considered a negligible trade-off for the gains in relevance.
---
### 6. Implementation Notes
**Implementation completed on 2025-11-16**
**Key Changes:**
1. **Dependencies** (pyproject.toml:25):
- Added `fastembed>=0.4.2` for BM25 sparse vector embeddings
- Adjusted `pillow` version constraint to be compatible with fastembed
2. **Qdrant Collection Schema** (nextcloud_mcp_server/vector/qdrant_client.py:113-128):
- Updated to named vectors: `{"dense": VectorParams(...), "sparse": SparseVectorParams(...)}`
- Added sparse vector configuration with BM25 index
- Maintains backward compatibility with existing collections (detects legacy schema)
3. **BM25 Embedding Provider** (nextcloud_mcp_server/embedding/bm25_provider.py):
- Created `BM25SparseEmbeddingProvider` using FastEmbed's `Qdrant/bm25` model
- Implements `encode()` and `encode_batch()` methods
- Returns sparse vectors as `{indices: list[int], values: list[float]}` format
4. **Document Indexing Pipeline** (nextcloud_mcp_server/vector/processor.py:229-255):
- Generates both dense (semantic) and sparse (BM25) embeddings for each document chunk
- Updates `PointStruct` to use named vectors: `vector={"dense": ..., "sparse": ...}`
- Maintains same chunking strategy (512 words, 50-word overlap)
5. **BM25 Hybrid Search Algorithm** (nextcloud_mcp_server/search/bm25_hybrid.py):
- Implements `BM25HybridSearchAlgorithm` using Qdrant's native RRF fusion
- Uses `prefetch` parameter for parallel dense + sparse search
- Applies `fusion=models.Fusion.RRF` for automatic result merging
- Maintains same deduplication and filtering logic as semantic search
6. **MCP Tool Updates** (nextcloud_mcp_server/server/semantic.py:39-68):
- Simplified `nc_semantic_search()` to use BM25 hybrid only
- Removed `algorithm`, `semantic_weight`, `keyword_weight`, `fuzzy_weight` parameters
- Updated default `score_threshold=0.0` for RRF scoring
- Returns `search_method="bm25_hybrid"` in responses
7. **Legacy Algorithm Removal**:
- Deleted `nextcloud_mcp_server/search/keyword.py` (278 lines)
- Deleted `nextcloud_mcp_server/search/fuzzy.py` (220 lines)
- Deleted `nextcloud_mcp_server/search/hybrid.py` (238 lines - custom RRF)
- Updated `nextcloud_mcp_server/search/__init__.py` to export only BM25 hybrid
**Migration Strategy:**
- No migration required (vector sync feature is experimental)
- New documents automatically indexed with both dense + sparse vectors
- Collection re-creation on first startup with updated schema
**Test Results:**
- All unit tests passing (118 passed)
- All integration tests passing (7 semantic search tests)
- Code formatting verified with ruff
**Benefits Realized:**
- ✅ Consolidated architecture (single Qdrant database for both dense + sparse)
- ✅ Native fusion algorithms (database-level, more efficient)
- ✅ Industry-standard BM25 (replaces custom keyword search)
- ✅ Simplified codebase (removed 736 lines of legacy code)
- ✅ Better relevance (handles both semantic and keyword queries)
- ✅ Configurable fusion methods (RRF and DBSF)
---
### 7. Fusion Algorithm Options
**Update: 2025-11-16**
The BM25 hybrid search now supports two fusion algorithms for combining dense (semantic) and sparse (BM25) search results:
#### Reciprocal Rank Fusion (RRF)
**Default fusion method.** RRF is a widely-used, well-established algorithm that combines rankings from multiple retrieval systems using the reciprocal rank formula:
```
RRF(doc) = Σ 1/(k + rank_i(doc))
```
where `k` is a constant (typically 60) and `rank_i(doc)` is the rank of the document in retrieval system `i`.
**Characteristics:**
-**General-purpose**: Works well across diverse query types and document collections
-**Rank-based**: Focuses on relative rankings rather than absolute scores
-**Established**: Well-tested, documented, and understood in IR literature
-**Robust**: Less sensitive to score distribution differences between systems
**When to use RRF:**
- Default choice for most use cases
- When you have mixed query types (semantic + keyword)
- When retrieval systems have very different score ranges
- When you want predictable, well-understood behavior
#### Distribution-Based Score Fusion (DBSF)
**Alternative fusion method.** DBSF normalizes scores from each retrieval system using distribution statistics before combining them:
1. **Normalization**: For each query, calculates mean (μ) and standard deviation (σ) of scores
2. **Outlier handling**: Uses μ ± 3σ as normalization bounds
3. **Fusion**: Sums normalized scores across systems
**Characteristics:**
-**Score-aware**: Uses actual relevance scores, not just rankings
-**Statistical**: Normalizes based on score distribution properties
- ⚠️ **Experimental**: Newer algorithm, less battle-tested than RRF
- ⚠️ **Sensitive**: May behave differently depending on score distributions
**When to use DBSF:**
- When retrieval systems have vastly different score ranges that RRF doesn't balance well
- When you want to experiment with score-based (vs rank-based) fusion
- When statistical normalization better matches your use case
- For A/B testing against RRF to measure retrieval quality improvements
#### Configuration
Both fusion algorithms are exposed via the `fusion` parameter in MCP tools:
```python
# Use RRF (default)
response = await nc_semantic_search(
query="async programming",
fusion="rrf" # Can be omitted, RRF is default
)
# Use DBSF
response = await nc_semantic_search(
query="async programming",
fusion="dbsf"
)
```
The `nc_semantic_search_answer` tool also supports the `fusion` parameter and passes it through to the underlying search.
#### Future: Configurable Weights
**Current limitation**: Neither RRF nor DBSF currently support per-system weights (e.g., 0.8 for semantic, 0.2 for BM25). This is a Qdrant platform limitation tracked in [qdrant/qdrant#6067](https://github.com/qdrant/qdrant/issues/6067).
When Qdrant adds weight support, the `fusion` parameter can be extended to accept weight configurations:
```python
# Hypothetical future API
response = await nc_semantic_search(
query="async programming",
fusion="rrf",
fusion_weights={"dense": 0.7, "sparse": 0.3} # Not yet implemented
)
```
**Recommendation**: Start with RRF (default). If you encounter cases where keyword matches are under- or over-weighted, experiment with DBSF. Monitor [qdrant/qdrant#6067](https://github.com/qdrant/qdrant/issues/6067) for configurable weight support.
@@ -0,0 +1,380 @@
# ADR-015: Unified Provider Architecture for Embeddings and Text Generation
**Status:** Accepted
**Date:** 2025-01-16
**Deciders:** Development Team
**Related:** ADR-003 (Vector Database), ADR-008 (MCP Sampling), ADR-013 (RAG Evaluation)
## Context
Prior to this refactoring, the codebase had two separate provider systems:
1. **Embedding Providers** (`nextcloud_mcp_server/embedding/`)
- Used `EmbeddingProvider` ABC with methods: `embed()`, `embed_batch()`, `get_dimension()`
- Had auto-detection via `EmbeddingService._detect_provider()`
- Used for semantic search and vector indexing (production)
2. **LLM Providers** (`tests/rag_evaluation/llm_providers.py`)
- Used `LLMProvider` Protocol with method: `generate()`
- Had separate factory function `create_llm_provider()`
- Used only for RAG evaluation tests (not production)
This fragmentation created several problems:
### Problems with Dual Provider Systems
1. **Code Duplication**
- Ollama configuration appeared in both `embedding/service.py` and `tests/rag_evaluation/llm_providers.py`
- Similar provider detection logic in multiple places
- Separate singleton patterns for each system
2. **Limited Extensibility**
- Hard-coded provider detection in `EmbeddingService._detect_provider()`
- No support for providers that offer both capabilities (like Bedrock)
- Adding new providers required modifying multiple files
3. **Inconsistent Patterns**
- BM25 provider didn't follow `EmbeddingProvider` ABC
- Different method names across providers (`embed` vs `encode`)
- ABC vs Protocol for type checking
4. **Difficult Scaling**
- Adding Amazon Bedrock (our third provider) would exacerbate all issues
- No clear path for future providers (OpenAI, Cohere, etc.)
### Amazon Bedrock Requirements
Bedrock naturally supports **both** embeddings and text generation:
- **Embeddings**: `amazon.titan-embed-text-v1/v2`, `cohere.embed-*`
- **Text Generation**: `anthropic.claude-*`, `meta.llama3-*`, `amazon.titan-text-*`
- **Unified API**: Single `invoke_model()` method via bedrock-runtime
This made it the perfect opportunity to establish a unified provider architecture.
## Decision
We refactored the provider infrastructure to use a **unified Provider ABC** with optional capabilities:
### 1. Unified Provider Interface
**New Structure:**
```
nextcloud_mcp_server/providers/
├── __init__.py
├── base.py # Provider ABC with optional capabilities
├── registry.py # Auto-detection and factory
├── ollama.py # Supports both embedding + generation
├── anthropic.py # Generation only
├── bedrock.py # Supports both embedding + generation
└── simple.py # Embedding only (testing fallback)
```
**Base Class (`providers/base.py`):**
```python
class Provider(ABC):
@property
@abstractmethod
def supports_embeddings(self) -> bool:
"""Whether this provider supports embedding generation."""
pass
@property
@abstractmethod
def supports_generation(self) -> bool:
"""Whether this provider supports text generation."""
pass
@abstractmethod
async def embed(self, text: str) -> list[float]:
"""Generate embedding (raises NotImplementedError if not supported)."""
pass
@abstractmethod
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
"""Generate batch embeddings (raises NotImplementedError if not supported)."""
pass
@abstractmethod
def get_dimension(self) -> int:
"""Get embedding dimension (raises NotImplementedError if not supported)."""
pass
@abstractmethod
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
"""Generate text (raises NotImplementedError if not supported)."""
pass
@abstractmethod
async def close(self) -> None:
"""Close provider and release resources."""
pass
```
### 2. Provider Registry
**Auto-Detection Priority** (`providers/registry.py`):
```python
class ProviderRegistry:
@staticmethod
def create_provider() -> Provider:
# 1. Bedrock (AWS_REGION or BEDROCK_*_MODEL)
# 2. Ollama (OLLAMA_BASE_URL)
# 3. Simple (fallback)
```
**Environment Variables:**
**Bedrock:**
- `AWS_REGION`: AWS region (e.g., "us-east-1")
- `AWS_ACCESS_KEY_ID`: AWS access key (optional, uses credential chain)
- `AWS_SECRET_ACCESS_KEY`: AWS secret key (optional)
- `BEDROCK_EMBEDDING_MODEL`: Model ID for embeddings (e.g., "amazon.titan-embed-text-v2:0")
- `BEDROCK_GENERATION_MODEL`: Model ID for text generation (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
**Ollama:**
- `OLLAMA_BASE_URL`: Ollama API base URL (e.g., "http://localhost:11434")
- `OLLAMA_EMBEDDING_MODEL`: Model for embeddings (default: "nomic-embed-text")
- `OLLAMA_GENERATION_MODEL`: Model for text generation (e.g., "llama3.2:1b")
- `OLLAMA_VERIFY_SSL`: Verify SSL certificates (default: "true")
**Simple (no configuration, fallback):**
- `SIMPLE_EMBEDDING_DIMENSION`: Embedding dimension (default: 384)
### 3. Backward Compatibility
**Old Code Continues to Work:**
```python
# Old way (still works)
from nextcloud_mcp_server.embedding import get_embedding_service
service = get_embedding_service() # Returns singleton Provider
embeddings = await service.embed_batch(texts)
```
**New Way (recommended):**
```python
# New way (cleaner)
from nextcloud_mcp_server.providers import get_provider
provider = get_provider() # Returns singleton Provider
embeddings = await provider.embed_batch(texts)
# Can also use generation if provider supports it
if provider.supports_generation:
text = await provider.generate("prompt")
```
**Migration Path:**
- `embedding/service.py` now wraps `providers.get_provider()` for compatibility
- `tests/rag_evaluation/llm_providers.py` now uses unified providers
- Old imports still work, marked as deprecated in docstrings
### 4. Amazon Bedrock Implementation
**Features:**
- Supports both embeddings and text generation
- Model-specific request/response handling for:
- Titan Embed (amazon.titan-embed-text-*)
- Cohere Embed (cohere.embed-*)
- Claude (anthropic.claude-*)
- Llama (meta.llama3-*)
- Titan Text (amazon.titan-text-*)
- Mistral (mistral.*)
- Uses boto3 bedrock-runtime client
- Graceful degradation if boto3 not installed
- Async implementation matching existing patterns
**Model-Specific Handling:**
```python
# Bedrock embedding request (Titan)
{"inputText": text}
# Bedrock generation request (Claude)
{
"anthropic_version": "bedrock-2023-05-31",
"max_tokens": max_tokens,
"temperature": 0.7,
"messages": [{"role": "user", "content": prompt}]
}
```
## Consequences
### Positive
1. **Sustainable Provider Additions**
- New providers only need to implement `Provider` ABC
- Auto-detection via environment variables
- No modifications to existing code required
2. **Code Consolidation**
- Single provider interface instead of two
- Unified configuration pattern
- Eliminated duplication
3. **Better Extensibility**
- Providers can support one or both capabilities
- Clear capability detection via properties
- Registry pattern simplifies auto-detection
4. **Improved Testing**
- RAG evaluation can use any provider (Ollama, Anthropic, Bedrock)
- Comprehensive unit tests for all providers
- Mocked boto3 tests for Bedrock
5. **Production-Ready Bedrock Support**
- Full embedding and generation support
- Multiple model families supported
- AWS credential chain integration
### Neutral
1. **Optional Boto3 Dependency**
- boto3 is dev dependency only (not required for core functionality)
- Bedrock provider gracefully fails if boto3 not installed
- Users who want Bedrock must `pip install boto3`
2. **Capability Properties**
- All providers must implement capability properties
- Methods raise `NotImplementedError` if capability not supported
- Clear error messages guide users to alternatives
### Negative
1. **Migration Effort**
- Existing code must be migrated to new imports (optional, backward compatible)
- Documentation needs updating
- Users must learn new environment variables
2. **Increased Complexity**
- Provider base class has more methods (embedding + generation)
- More environment variables to configure
- Capability detection adds runtime checks
## Implementation
### Files Created
**New Provider Infrastructure:**
- `nextcloud_mcp_server/providers/__init__.py`
- `nextcloud_mcp_server/providers/base.py`
- `nextcloud_mcp_server/providers/registry.py`
- `nextcloud_mcp_server/providers/ollama.py`
- `nextcloud_mcp_server/providers/anthropic.py`
- `nextcloud_mcp_server/providers/bedrock.py`
- `nextcloud_mcp_server/providers/simple.py`
**Tests:**
- `tests/unit/providers/__init__.py`
- `tests/unit/providers/test_bedrock.py` (9 unit tests)
**Documentation:**
- `docs/ADR-015-unified-provider-architecture.md` (this file)
### Files Modified
**Backward Compatibility:**
- `nextcloud_mcp_server/embedding/service.py` - Now wraps `get_provider()`
- `tests/rag_evaluation/llm_providers.py` - Uses unified providers
**Dependencies:**
- `pyproject.toml` - Added `boto3>=1.35.0` to dev dependencies
### Testing Results
**Unit Tests:** 127 passed (including 9 new Bedrock tests)
**Type Checking:** All checks passed (ty)
**Linting:** All checks passed (ruff)
**Backward Compatibility:** Verified - existing embedding tests work
## Alternatives Considered
### Alternative 1: Keep Separate Provider Systems
**Pros:**
- No refactoring needed
- Simpler short-term
**Cons:**
- Bedrock would need to be implemented twice
- Continued code duplication
- No long-term scalability
**Decision:** Rejected - technical debt would continue to grow
### Alternative 2: Separate Embedding and Generation Providers
Use composition instead of unified interface:
```python
class CombinedProvider:
def __init__(self, embedding: EmbeddingProvider, generation: LLMProvider):
self.embedding = embedding
self.generation = generation
```
**Pros:**
- Clearer separation of concerns
- Simpler individual providers
**Cons:**
- Bedrock and Ollama naturally do both - artificial separation
- More complex configuration (two providers to configure)
- More boilerplate code
**Decision:** Rejected - unified interface better matches provider capabilities
### Alternative 3: Plugin System
Dynamic provider registration via entry points:
```python
# setup.py
entry_points={
'nextcloud_mcp.providers': [
'ollama = nextcloud_mcp_server.providers.ollama:OllamaProvider',
'bedrock = nextcloud_mcp_server.providers.bedrock:BedrockProvider',
]
}
```
**Pros:**
- Most extensible
- Third-party providers possible
**Cons:**
- Over-engineered for current needs
- Added complexity
- No immediate benefit
**Decision:** Deferred - can add later if needed
## Future Work
1. **Additional Providers**
- OpenAI (embeddings + generation)
- Cohere (embeddings + generation)
- Google Vertex AI
- Azure OpenAI
2. **Provider Features**
- Streaming generation support
- Batch API optimization (when available)
- Model-specific optimizations
- Cost tracking and metrics
3. **Configuration Improvements**
- Provider profiles (development, production)
- Model aliasing (e.g., "small", "large")
- Fallback provider chains
4. **Testing**
- Integration tests with real Bedrock endpoints
- Performance benchmarking across providers
- Cost comparison analysis
## References
- [boto3 Bedrock Runtime Documentation](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime.html)
- [Amazon Bedrock User Guide](https://docs.aws.amazon.com/bedrock/latest/userguide/what-is-bedrock.html)
- ADR-003: Vector Database and Semantic Search
- ADR-008: MCP Sampling for Semantic Search
- ADR-013: RAG Evaluation Framework
@@ -0,0 +1,492 @@
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
**Status:** Proposed
**Date:** 2025-01-22
**Deciders:** Development Team
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
## Context
[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides:
- **Discovery**: Marketplace listing for MCP servers
- **Hosting**: Containerized deployment with auto-scaling
- **Authentication UI**: OAuth flow presentation for users
- **Session Configuration**: Per-user settings passed via URL parameters
- **Observability**: Usage logs and monitoring
### Current Architecture Limitations
The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with:
1. **Persistent Infrastructure**
- Qdrant vector database for semantic search
- Background sync worker for content indexing
- Refresh token storage for offline access
2. **Single-Tenant Configuration**
- Environment variables configure one Nextcloud instance
- `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
- Or OAuth with a single IdP
3. **Stateful Operations**
- Vector sync maintains index state across requests
- Token storage persists between sessions
### Smithery Hosting Constraints
Smithery-hosted containers are **stateless by design**:
- No persistent storage between requests
- No background workers or cron jobs
- No databases (Qdrant, Redis, etc.)
- Containers may be recycled at any time
- Configuration passed per-session via URL parameters
### Opportunity
Many users have **publicly accessible Nextcloud instances** and want to:
1. Try the MCP server without self-hosting infrastructure
2. Connect multiple users to different Nextcloud instances
3. Use basic Nextcloud tools without semantic search
4. Benefit from Smithery's discovery and OAuth UI
## Decision
Implement a **stateless deployment mode** for Smithery that:
1. **Disables stateful features** (vector sync, semantic search)
2. **Creates clients per-session** from Smithery configuration
3. **Supports multiple Nextcloud instances** via session config
4. **Provides a useful subset of tools** that work without infrastructure
### Architecture
```
┌─────────────────────────────────────────────────────────────────────────┐
│ Smithery-Hosted Stateless Mode │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ MCP Client Smithery │
│ (Cursor, Claude) Infrastructure │
│ │ │ │
│ │ 1. Connect │ │
│ ├───────────────────────────►│ │
│ │ │ │
│ │ 2. Config UI │ │
│ │◄───────────────────────────┤ User enters: │
│ │ (Smithery presents) │ - nextcloud_url │
│ │ │ - auth_mode (basic/oauth) │
│ │ │ - credentials │
│ │ 3. Tool call │ │
│ ├───────────────────────────►│ │
│ │ + session config │ │
│ │ │ │
│ │ ┌───────┴───────┐ │
│ │ │ MCP Server │ │
│ │ │ Container │ │
│ │ │ │ │
│ │ │ 4. Create │ │
│ │ │ client │ │
│ │ │ from │ │
│ │ │ config │ │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ │ 5. Call │ │
│ │ │ Nextcloud │───────► User's Nextcloud │
│ │ │ API │ Instance │
│ │ │ │ │ │
│ │ │ ▼ │ │
│ │ 6. Response │ Return result │ │
│ │◄───────────────────┤ │ │
│ │ └───────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘
```
### Session Configuration Schema
```python
from pydantic import BaseModel, Field
class SmitheryConfigSchema(BaseModel):
"""Configuration schema for Smithery session."""
# Required: Nextcloud instance
nextcloud_url: str = Field(
...,
description="Your Nextcloud instance URL (e.g., https://cloud.example.com)"
)
# Authentication mode
auth_mode: str = Field(
"app_password",
description="Authentication method: 'app_password' or 'oauth'"
)
# App Password authentication (recommended for Smithery)
username: str | None = Field(
None,
description="Nextcloud username (required for app_password auth)"
)
app_password: str | None = Field(
None,
description="Nextcloud app password (Settings → Security → App passwords)"
)
# OAuth authentication (advanced)
# When auth_mode='oauth', Smithery handles the OAuth flow
# and passes the access token automatically
```
### Feature Matrix
| Feature | Self-Hosted | Smithery Stateless |
|---------|-------------|-------------------|
| **Notes** | | |
| List/Search notes | ✓ | ✓ |
| Get/Create/Update notes | ✓ | ✓ |
| Semantic search | ✓ | ✗ |
| **Calendar** | | |
| List calendars | ✓ | ✓ |
| Get/Create events | ✓ | ✓ |
| **Contacts** | | |
| List address books | ✓ | ✓ |
| Search/Get contacts | ✓ | ✓ |
| **Files (WebDAV)** | | |
| List/Download files | ✓ | ✓ |
| Upload files | ✓ | ✓ |
| Search files | ✓ | ✓ (keyword only) |
| **Deck** | | |
| List boards/cards | ✓ | ✓ |
| Create/Update cards | ✓ | ✓ |
| **Tables** | | |
| List/Query tables | ✓ | ✓ |
| Create/Update rows | ✓ | ✓ |
| **Cookbook** | | |
| List/Get recipes | ✓ | ✓ |
| **Semantic Search** | | |
| Vector search | ✓ | ✗ |
| RAG answers | ✓ | ✗ |
| **Background Sync** | | |
| Auto-indexing | ✓ | ✗ |
| Webhook sync | ✓ | ✗ |
| **Admin UI (`/app`)** | | |
| Vector sync status | ✓ | ✗ |
| Vector visualization | ✓ | ✗ |
| Webhook management | ✓ | ✗ |
| Session management | ✓ | ✗ |
### Implementation
#### 1. Deployment Mode Detection
```python
# nextcloud_mcp_server/config.py
class DeploymentMode(Enum):
SELF_HOSTED = "self_hosted" # Full features, env-based config
SMITHERY_STATELESS = "smithery" # Stateless, session-based config
def get_deployment_mode() -> DeploymentMode:
"""Detect deployment mode from environment."""
if os.getenv("SMITHERY_DEPLOYMENT") == "true":
return DeploymentMode.SMITHERY_STATELESS
return DeploymentMode.SELF_HOSTED
```
#### 2. Session-Based Client Factory
```python
# nextcloud_mcp_server/context.py
async def get_client(ctx: Context) -> NextcloudClient:
"""Get NextcloudClient - from session config or environment."""
mode = get_deployment_mode()
if mode == DeploymentMode.SMITHERY_STATELESS:
# Create client from Smithery session config
config = ctx.session_config
if not config:
raise McpError("Session configuration required")
return NextcloudClient(
base_url=config.nextcloud_url,
username=config.username,
password=config.app_password,
)
else:
# Existing behavior: from environment or OAuth context
return await _get_client_from_context(ctx)
```
#### 3. Conditional Tool Registration
```python
# nextcloud_mcp_server/app.py
def create_mcp_server(mode: DeploymentMode) -> FastMCP:
"""Create MCP server with mode-appropriate tools."""
mcp = FastMCP("Nextcloud MCP")
# Always register core tools
configure_notes_tools(mcp)
configure_calendar_tools(mcp)
configure_contacts_tools(mcp)
configure_webdav_tools(mcp)
configure_deck_tools(mcp)
configure_tables_tools(mcp)
configure_cookbook_tools(mcp)
# Only register stateful tools in self-hosted mode
if mode == DeploymentMode.SELF_HOSTED:
configure_semantic_tools(mcp) # Requires Qdrant
register_oauth_tools(mcp) # Requires token storage
return mcp
```
#### 4. Exclude Admin UI Routes
The `/app` admin UI should **not be installed** in Smithery mode because:
- **Vector sync status** - No vector sync in stateless mode
- **Vector visualization** - No Qdrant to visualize
- **Webhook management** - No webhook sync without background workers
- **Session management** - No persistent sessions to manage
```python
# nextcloud_mcp_server/app.py
def create_app(mode: DeploymentMode) -> Starlette:
"""Create Starlette app with mode-appropriate routes."""
routes = [
Route("/health/live", health_live, methods=["GET"]),
Route("/health/ready", health_ready, methods=["GET"]),
]
# Only mount admin UI in self-hosted mode
if mode == DeploymentMode.SELF_HOSTED:
browser_app = create_browser_app()
routes.append(
Route("/app", lambda r: RedirectResponse("/app/", status_code=307))
)
routes.append(Mount("/app", app=browser_app))
logger.info("Admin UI mounted at /app")
else:
logger.info("Admin UI disabled in Smithery stateless mode")
# Mount FastMCP at root
mcp_app = create_mcp_server(mode).streamable_http_app()
routes.append(Mount("/", app=mcp_app))
return Starlette(routes=routes, lifespan=starlette_lifespan)
```
**Endpoints by Mode:**
| Endpoint | Self-Hosted | Smithery |
|----------|-------------|----------|
| `/mcp` | ✓ | ✓ |
| `/health/live` | ✓ | ✓ |
| `/health/ready` | ✓ | ✓ |
| `/.well-known/mcp-config` | ✓ | ✓ |
| `/app` | ✓ | ✗ |
| `/app/vector-sync/status` | ✓ | ✗ |
| `/app/vector-viz` | ✓ | ✗ |
| `/app/webhooks` | ✓ | ✗ |
#### 5. Smithery Integration Files
**smithery.yaml:**
```yaml
runtime: "container"
build:
dockerfile: "Dockerfile.smithery"
dockerBuildPath: "."
startCommand:
type: "http"
configSchema:
type: "object"
required: ["nextcloud_url", "username", "app_password"]
properties:
nextcloud_url:
type: "string"
title: "Nextcloud URL"
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com)"
username:
type: "string"
title: "Username"
description: "Your Nextcloud username"
app_password:
type: "string"
title: "App Password"
description: "Generate at Settings → Security → App passwords"
exampleConfig:
nextcloud_url: "https://cloud.example.com"
username: "alice"
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
```
**Dockerfile.smithery:**
```dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
# Copy project files
COPY pyproject.toml uv.lock ./
COPY nextcloud_mcp_server ./nextcloud_mcp_server
# Install dependencies (without vector/semantic extras)
RUN uv sync --frozen --no-dev
# Set Smithery mode
ENV SMITHERY_DEPLOYMENT=true
ENV VECTOR_SYNC_ENABLED=false
# Smithery sets PORT=8081
EXPOSE 8081
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
```
**nextcloud_mcp_server/smithery_main.py:**
```python
"""Smithery-specific entrypoint for stateless deployment."""
import os
import uvicorn
from starlette.middleware.cors import CORSMiddleware
from nextcloud_mcp_server.app import create_mcp_server
from nextcloud_mcp_server.config import DeploymentMode
def main():
# Force stateless mode
os.environ["SMITHERY_DEPLOYMENT"] = "true"
os.environ["VECTOR_SYNC_ENABLED"] = "false"
mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS)
app = mcp.streamable_http_app()
# Add CORS for browser-based clients
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
expose_headers=["mcp-session-id", "mcp-protocol-version"],
)
# Smithery sets PORT environment variable
port = int(os.environ.get("PORT", 8081))
uvicorn.run(app, host="0.0.0.0", port=port)
if __name__ == "__main__":
main()
```
### Security Considerations
1. **App Passwords over User Passwords**
- Smithery config encourages app passwords (revocable, scoped)
- Documentation guides users to create dedicated app passwords
- App passwords can be revoked without changing main password
2. **HTTPS Required**
- `nextcloud_url` must be HTTPS for production use
- Validation rejects HTTP URLs in Smithery mode
3. **No Credential Storage**
- Credentials exist only for request duration
- No server-side persistence of user credentials
- Smithery handles secure config transmission
4. **Scope Limitation**
- Stateless mode cannot access offline_access
- No background operations on user's behalf
- Clear user expectation: tools work during session only
### Migration Path
Users can start with Smithery stateless mode and migrate to self-hosted:
1. **Try on Smithery** → Basic tools, no setup
2. **Self-host for semantic search** → Add Qdrant, enable vector sync
3. **Full deployment** → Background sync, webhooks, multi-user OAuth
## Consequences
### Positive
1. **Lower barrier to entry** - Users can try without infrastructure
2. **Multi-user support** - Each session connects to different Nextcloud
3. **Smithery ecosystem** - Discovery, observability, OAuth UI
4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full)
### Negative
1. **No semantic search** - Key differentiator unavailable on Smithery
2. **Per-request auth** - Credentials sent with each request
3. **No offline access** - Cannot perform background operations
4. **Maintenance burden** - Two deployment modes to support
### Neutral
1. **Feature subset** - May encourage users to self-host for full features
2. **Documentation needs** - Clear guidance on mode differences required
## Alternatives Considered
### 1. External MCP Only
**Approach:** Only support self-hosted external MCP registration on Smithery.
**Rejected because:**
- Higher barrier to entry for new users
- Misses opportunity for Smithery marketplace visibility
- Users want to try before committing to infrastructure
### 2. Embedded Vector DB (SQLite-vec)
**Approach:** Use SQLite with vector extensions for per-request indexing.
**Rejected because:**
- No persistence between requests anyway
- Indexing latency too high for synchronous requests
- Complexity without benefit in stateless context
### 3. External Vector DB Service
**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container.
**Rejected because:**
- Adds external dependency and cost
- Per-user collections require complex multi-tenancy
- Sync still impossible without background workers
### 4. Hybrid: Smithery + User's Qdrant
**Approach:** User provides their own Qdrant URL in session config.
**Considered for future:**
- Could enable semantic search for advanced users
- Adds complexity to session config
- Sync still requires external trigger (manual or webhook)
## References
- [Smithery Documentation](https://smithery.ai/docs)
- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config)
- [Smithery External MCPs](https://smithery.ai/docs/build/external)
- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports)
- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords)
+506
View File
@@ -0,0 +1,506 @@
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
## Status
Implemented
## Context
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
- No behavioral hints for clients about read-only, destructive, or idempotent operations
- Missing parameter descriptions for better auto-completion and inline help
- Clients cannot optimize caching, warn before destructive operations, or retry safely
### Available MCP Annotations
The MCP SDK provides three types of annotations:
#### 1. Tool Decorator Parameters
```python
@mcp.tool(
title="Human-Readable Name",
description="Tool description", # Can also come from docstring
annotations=ToolAnnotations(...),
icons=[Icon(...)] # Optional visual icons
)
```
#### 2. ToolAnnotations Behavioral Hints
```python
from mcp.types import ToolAnnotations
ToolAnnotations(
title="Alternative Title", # Decorator title takes precedence
readOnlyHint=True, # Tool doesn't modify data
destructiveHint=True, # Tool may delete/overwrite data
idempotentHint=True, # Repeated calls with same args are safe
openWorldHint=True # Interacts with external entities
)
```
#### 3. Parameter Descriptions
```python
from pydantic import Field
async def tool(
param: str = Field(description="What this parameter does"),
ctx: Context
):
```
### Idempotency Analysis
**Important**: Idempotency means calling with **the same inputs** produces the same result.
**NOT Idempotent** (different inputs each call):
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
- Different input (stale etag) → different result (error)
- **Creates**: `create_note(title="X")` → creates note 1
- Second call → creates note 2 (different result)
- **Append operations**: `append_content(id=1, text="X")` → adds X once
- Second call → adds X again (different result)
**Idempotent**:
- **Deletes**: `delete_note(id=1)` → note deleted
- Second call → 404 or success (same end state: note doesn't exist)
- Note: May return different status code, but end state is identical
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
- Second call → file still has "Hello" (same end state)
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
- **Set operations**: `set_property(id=1, value="X")` → property = X
- Second call → property still = X (same result)
- Note: Nextcloud updates with etags use version control, so not idempotent
**Read-Only** (always idempotent, never destructive):
- All list, search, get operations
## Decision
Add annotations to all 101 tools in three phases:
### Phase 1: Titles (Quick Win)
Add human-readable titles to all tools:
```python
@mcp.tool(title="Create Note")
async def nc_notes_create_note(...):
```
**Effort**: 2-3 hours
**Impact**: Immediate UX improvement
### Phase 2: ToolAnnotations (Behavioral Hints)
Add annotations based on corrected categorization:
```python
# Read-only tools
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True # Nextcloud is external to MCP server
)
)
# Delete tools (idempotent: same end state)
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True,
idempotentHint=True, # Deleting deleted item = same end state
openWorldHint=True
)
)
# Create tools (not idempotent: creates multiple items)
@mcp.tool(
title="Create Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
# Update tools with etag (not idempotent: etag changes)
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # Etag required = different inputs each time
openWorldHint=True
)
)
# Append operations (not idempotent: adds content each time)
@mcp.tool(
title="Append to Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
```
**Effort**: 4-6 hours
**Impact**: Better client behavior (caching, warnings, retry logic)
### Phase 3: Parameter Descriptions
Add Field() descriptions to parameters:
```python
from pydantic import Field
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
async def nc_notes_create_note(
title: str = Field(description="The title of the note"),
content: str = Field(description="Markdown content of the note"),
category: str = Field(description="Category or folder name for organizing"),
ctx: Context
) -> CreateNoteResponse:
```
**Effort**: 6-8 hours
**Impact**: Better auto-completion and inline help
## Tool Categorization
### Read-Only Tools (~40 tools)
**Pattern**: List, search, get operations
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_search_notes` → "Search Notes"
- `nc_webdav_list_directory` → "List Files and Directories"
- `nc_calendar_list_calendars` → "List Calendars"
- `nc_contacts_get_contact` → "Get Contact"
- `nc_semantic_search` → "Semantic Search"
- `check_logged_in` → "Check Server Login Status"
### Create Tools (~20 tools)
**Pattern**: Create new resources
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_create_note` → "Create Note"
- `nc_calendar_create_event` → "Create Calendar Event"
- `nc_contacts_create_contact` → "Create Contact"
- `deck_create_card` → "Create Kanban Card"
- `nc_tables_create_row` → "Create Table Row"
### Update Tools (~25 tools)
**Pattern**: Modify existing resources with etag
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
Examples:
- `nc_notes_update_note` → "Update Note"
- `nc_calendar_update_event` → "Update Calendar Event"
- `nc_contacts_update_contact` → "Update Contact"
- `deck_update_card` → "Update Kanban Card"
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
### Append/Accumulate Tools (~5 tools)
**Pattern**: Add content without replacing
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_append_content` → "Append to Note"
**Rationale**: Each call adds content, changing the result = NOT idempotent.
### Delete Tools (~10 tools)
**Pattern**: Remove resources
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_delete_note` → "Delete Note"
- `nc_webdav_delete_resource` → "Delete File or Directory"
- `nc_calendar_delete_event` → "Delete Calendar Event"
- `nc_contacts_delete_contact` → "Delete Contact"
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
### Special Cases
#### OAuth Provisioning Tools
```python
# Not read-only but requires user interaction
@mcp.tool(
title="Grant Server Access to Nextcloud",
annotations=ToolAnnotations(
readOnlyHint=False,
idempotentHint=False, # Creates new OAuth session each time
openWorldHint=True
)
)
async def provision_nextcloud_access(ctx: Context):
```
#### Semantic Search (Closed World)
```python
@mcp.tool(
title="Semantic Search",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=False # Searches only indexed Nextcloud data
)
)
async def nc_semantic_search(query: str, ctx: Context):
```
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
## Tool Priority Matrix
### Critical Priority (~2 tools)
OAuth tools required for server functionality:
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
- `check_logged_in` → "Check Server Login Status"
### High Priority (~50 tools)
Most commonly used modules:
- **Notes** (14 tools): Create, read, update, delete notes
- **WebDAV** (13 tools): File operations
- **Calendar** (15 tools): Events and todos
- **Semantic Search** (6 tools): AI-powered search
- **Contacts** (9 tools): Address book operations
### Medium Priority (~35 tools)
Secondary functionality:
- **Deck** (9 tools): Kanban boards
- **Tables** (7 tools): Structured data
- **Sharing** (5 tools): File sharing
### Low Priority (~14 tools)
Less frequently used:
- **Cookbook** (8 tools): Recipe management
- **News** (6 tools): RSS feeds
## Implementation Plan
### Week 1: Phase 1 - Titles
- Add human-readable titles to all 101 tools
- Update tool name mapping in documentation
- Manual test in MCP inspector
### Week 2: Phase 2 - ToolAnnotations (High Priority)
- Add annotations to Critical and High priority tools (~52 tools)
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
- Add unit tests validating annotation presence
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
- Complete remaining tools (~49 tools)
- Deck, Tables, Contacts, Cookbook, News
- Update tool listings in README
### Week 4: Phase 3 - Parameter Descriptions
- Add Field() descriptions to Critical/High priority tools
- Start with OAuth, Notes, WebDAV modules
- Incremental completion over time
## Benefits
### For Users
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
- **Safety**: Warnings before destructive operations
- **Better help**: Parameter descriptions in auto-completion
- **Confidence**: Know which operations are safe to retry
### For MCP Clients
- **Caching**: Cache results from read-only tools
- **Safety prompts**: Warn before destructiveHint=true
- **Retry logic**: Safely retry idempotent operations
- **UI organization**: Group by behavior (reads vs writes vs deletes)
- **Performance**: Optimize based on hints
### For Developers
- **Self-documenting**: Behavior is explicit
- **Consistency**: Standard patterns across codebase
- **Testing**: Validate annotations match implementation
- **Maintenance**: Clear expectations for new tools
## Consequences
### Positive
- Immediate UX improvement with minimal effort
- Clients can make smarter decisions
- Self-documenting code
- Follows MCP best practices
### Negative
- Initial effort to add annotations (12-15 hours total)
- Must maintain annotations when adding new tools
- Risk of incorrect annotations misleading clients
### Neutral
- Annotations are hints, not guarantees
- Clients may ignore annotations
- Backward compatible (additive change)
### Mitigations
- **Incorrect annotations**: Add tests validating behavior matches hints
- **Maintenance burden**: Add to code review checklist and tool template
- **Documentation**: Update CLAUDE.md with annotation guidelines
## Examples
### Complete Annotated Tool (Delete)
```python
from mcp.types import ToolAnnotations
from pydantic import Field
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True, # Deletes data permanently
idempotentHint=True, # Same end state (note doesn't exist)
openWorldHint=True # Nextcloud is external
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_delete_note(
note_id: int = Field(description="The ID of the note to delete permanently"),
ctx: Context
) -> DeleteNoteResponse:
"""Delete a note permanently (requires notes:write scope)"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Update)
```python
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # NOT idempotent: etag changes each update
openWorldHint=True
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_update_note(
note_id: int = Field(description="The ID of the note to update"),
title: str | None = Field(
default=None,
description="New title (omit to keep current)"
),
content: str | None = Field(
default=None,
description="New markdown content (omit to keep current)"
),
category: str | None = Field(
default=None,
description="New category/folder (omit to keep current)"
),
etag: str = Field(
description="ETag from get_note (prevents concurrent modification)"
),
ctx: Context
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category.
The etag parameter is required to prevent overwriting concurrent changes.
Get the current ETag by first calling nc_notes_get_note.
If the note has been modified since you retrieved it, the update will fail.
"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Read-Only)
```python
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True, # Doesn't modify data
openWorldHint=True # Queries Nextcloud
)
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_search_notes(
query: str = Field(description="Search term to match in note titles or content"),
ctx: Context
) -> SearchNotesResponse:
"""Search notes by title or content, returning id, title, and category.
This is a read-only operation that searches across all user notes.
Use nc_notes_get_note to retrieve the full content of matching notes.
"""
client = await get_client(ctx)
# ... implementation ...
```
## Testing Strategy
### Unit Tests
Add tests validating annotation presence and correctness:
```python
def test_notes_tools_have_annotations():
"""Verify all notes tools have appropriate annotations."""
tools = get_registered_tools(mcp)
# Check create tool
create_tool = tools["nc_notes_create_note"]
assert create_tool.title == "Create Note"
assert create_tool.annotations.idempotentHint is False
# Check delete tool
delete_tool = tools["nc_notes_delete_note"]
assert delete_tool.title == "Delete Note"
assert delete_tool.annotations.destructiveHint is True
assert delete_tool.annotations.idempotentHint is True
# Check read-only tool
search_tool = tools["nc_notes_search_notes"]
assert search_tool.title == "Search Notes"
assert search_tool.annotations.readOnlyHint is True
```
### Integration Tests
- Verify existing tests pass with annotations
- Manual testing in MCP inspector/client
### Documentation Updates
- Update README tool listings with new titles
- Add annotation guidelines to CLAUDE.md
- Include examples in developer documentation
## Resolved Questions
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
- **Decision**: Mark as `idempotentHint=True`
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
- **Decision**: Mark as `openWorldHint=True`
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
## Future Considerations
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
## References
- MCP Python SDK: `/home/chris/Software/python-sdk/`
- ToolAnnotations spec: `src/mcp/types.py:1247`
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
## Decision Timeline
- **Proposed**: 2025-12-11
- **Reviewed**: 2025-12-11 (Self-review during implementation)
- **Accepted**: 2025-12-11
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,342 @@
# ADR-020: Deployment Modes and Configuration Validation
**Status:** Accepted
**Date:** 2025-12-20
**Deciders:** Development Team
**Related:** ADR-002 (Vector Sync), ADR-004 (Progressive Consent), ADR-019 (Multi-user BasicAuth)
## Context
The MCP server supports multiple deployment scenarios with different authentication methods, storage backends, and feature sets. Over time, the configuration system evolved to support ~500+ possible combinations across deployment modes, authentication patterns, and feature toggles. This complexity made it difficult to:
1. Understand what configuration is required for a given deployment
2. Debug configuration errors (validation scattered across multiple files)
3. Provide helpful error messages when configuration is invalid
4. Maintain clear boundaries between deployment modes
**Problems Identified:**
- No single source of truth for "what config is required for mode X"
- Validation happening at 4+ different points (Settings.__post_init__, setup_oauth_config(), context helpers, starlette_lifespan)
- Startup sequence unclear (OAuth setup before FastMCP creation, sync initialization errors)
- Error messages generic ("X is required") without explaining which deployment mode triggered the requirement
- Multiple overlapping decision trees (deployment mode, auth mode, features)
## Decision
We formalize five distinct deployment modes with explicit configuration requirements and implement centralized configuration validation.
### Deployment Modes
#### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password # Or app password
```
**Optional Configuration:**
```bash
# Vector sync (semantic search)
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=/path/to/qdrant # Or QDRANT_URL for remote
# Embeddings (optional - Simple provider used as fallback)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Document processing
DOCUMENT_CHUNK_SIZE=512
DOCUMENT_CHUNK_OVERLAP=50
```
**Characteristics:**
- Single shared NextcloudClient created at startup
- No OAuth infrastructure needed
- No multi-user support
- Vector sync runs as single-user background task
- Admin UI available at /app
---
#### 2. Multi-User BasicAuth Pass-Through
**Use Case:** Internal deployment where users provide their own credentials, no background sync needed
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
```
**Optional Configuration:**
```bash
# For background sync (requires app passwords from Astrolabe)
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- No OAuth for client authentication (uses BasicAuth in request headers)
- BasicAuthMiddleware extracts credentials from Authorization header
- Client created per-request from extracted credentials
- Optional: Background sync using app passwords (via Astrolabe API)
- Admin UI available at /app
---
#### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication, tokens work for both MCP and Nextcloud
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Auto-Configured:**
- OIDC discovery URL: `{NEXTCLOUD_HOST}/.well-known/openid-configuration`
- Client credentials: Dynamic Client Registration (DCR) if available
- Token storage: SQLite at `~/.oauth/clients.db`
**Optional Configuration:**
```bash
# Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Offline access for background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
# Scopes
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write ..."
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- Tokens contain both `aud: ["mcp-server", "nextcloud"]`
- Pass token through to Nextcloud APIs (no exchange)
- Client created per-request from token in Authorization header
- Background sync uses refresh tokens (if offline_access enabled)
- Admin UI available at /app
---
#### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP token is separate from Nextcloud token
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Optional Configuration:**
- Same as OAuth Single-Audience, plus:
```bash
TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens
```
**Characteristics:**
- Tokens contain only `aud: "mcp-server"`
- MCP server exchanges token for Nextcloud token via RFC 8693
- Exchanged tokens cached per-user
- Client created per-request using exchanged token
- Background sync uses refresh tokens (if offline_access enabled)
---
#### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform
**Required Configuration:**
- None! Configuration comes from session URL params: `?nextcloud_url=...&username=...&app_password=...`
**Forbidden Configuration:**
- Must NOT set: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`, `ENABLE_MULTI_USER_BASIC_AUTH`, `ENABLE_TOKEN_EXCHANGE`, `ENABLE_OFFLINE_ACCESS`, `VECTOR_SYNC_ENABLED`, `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`
**Characteristics:**
- No persistent storage (stateless)
- Client created per-request from session config
- No vector sync (disabled)
- No admin UI (no /app routes)
- No OAuth infrastructure
---
### Configuration Validation
**Implementation:** `nextcloud_mcp_server/config_validators.py`
**Key Functions:**
```python
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Priority (most specific to most general):
1. Smithery (explicit flag)
2. Token exchange (most specific OAuth mode)
3. Multi-user BasicAuth
4. Single-user BasicAuth
5. OAuth single-audience (default OAuth mode)
"""
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
```
**Validation Rules:**
- **Required variables:** Must be set and non-empty
- **Forbidden variables:** Must NOT be set (or must be False for booleans)
- **Conditional requirements:** If feature X is enabled, requires variables Y and Z
**Error Messages:**
```
Configuration validation failed for {mode} mode:
- [{mode}] Missing required configuration: NEXTCLOUD_HOST
- [{mode}] ENABLE_OFFLINE_ACCESS must be enabled when VECTOR_SYNC_ENABLED is true
Mode: {mode}
Description: {mode_description}
Required configuration:
- VAR1
- VAR2
Optional configuration:
- VAR3
- VAR4
Conditional requirements:
When FEATURE is enabled:
- VAR5
- VAR6
```
**Integration:**
- Validation runs at app startup in `get_app()` (app.py:1048-1062)
- All errors reported before any initialization begins
- Mode-specific error messages explain requirements
- Validation uses the same Settings object used throughout the app
### Configuration Matrix
| Variable | Single BasicAuth | Multi BasicAuth | OAuth Single | OAuth Exchange | Smithery |
|----------|------------------|-----------------|--------------|----------------|----------|
| **NEXTCLOUD_HOST** | Required | Required | Required | Required | Forbidden |
| **NEXTCLOUD_USERNAME** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **NEXTCLOUD_PASSWORD** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **ENABLE_MULTI_USER_BASIC_AUTH** | Forbidden | Required | Forbidden | Forbidden | Forbidden |
| **ENABLE_TOKEN_EXCHANGE** | Forbidden | Forbidden | Forbidden | Required | Forbidden |
| **ENABLE_OFFLINE_ACCESS** | Optional\* | Optional\* | Optional\* | Optional\* | Forbidden |
| **TOKEN_ENCRYPTION_KEY** | If offline | If offline | If offline | If offline | Forbidden |
| **TOKEN_STORAGE_DB** | If offline | If offline | If offline | If offline | Forbidden |
| **OIDC_CLIENT_ID** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **OIDC_CLIENT_SECRET** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **VECTOR_SYNC_ENABLED** | Optional | Optional | Optional | Optional | Forbidden |
| **QDRANT_URL/LOCATION** | If vector | If vector | If vector | If vector | Forbidden |
| **OLLAMA_BASE_URL/OPENAI_API_KEY** | Optional | Optional | Optional | Optional | Forbidden |
\* Only enables background sync for semantic search
\*\* Uses DCR if not provided
## Consequences
### Positive
1. **Clarity:** Single function to detect mode from config
2. **Validation:** All config validated upfront with helpful errors
3. **Debugging:** Clear logs showing "Running in X mode with config Y"
4. **Maintenance:** Mode-specific logic can be isolated
5. **Documentation:** Clear mapping of mode → required config
6. **Error Messages:** Context-aware ("X is required for Y mode")
7. **Testing:** Each mode testable in isolation
### Negative
1. **Migration:** Existing invalid configurations will now fail at startup
2. **Flexibility:** Less flexibility in configuration combinations
3. **Strictness:** Some previously-working combinations may be rejected
### Neutral
1. **Backward Compatibility:** Valid configurations continue to work
2. **Mode Detection:** Automatic based on config (no explicit mode selection)
3. **Default Mode:** OAuth single-audience when no credentials provided
## Implementation Notes
### Embedding Provider Validation
Originally, validation required either `OLLAMA_BASE_URL` or `OPENAI_API_KEY` when vector sync was enabled. This was too strict because the Simple provider is always available as a fallback (ADR-015). The validation was removed to allow vector sync without explicit provider configuration.
### Variable Scoping Issues
During implementation, several Python variable scoping issues were discovered in `app.py`:
- Local variable assignments in `starlette_lifespan()` shadowed outer scope variables
- Fixed by using unique variable names (e.g., `nextcloud_host_for_context`, `basic_auth_storage`)
- Removed redundant `settings = get_settings()` call (re-used outer scope)
### Docker Compose Configuration
The `mcp-oauth` service configuration was updated to remove `ENABLE_MULTI_USER_BASIC_AUTH=true` which conflicted with its intended OAuth mode. The service now runs in OAuth single-audience mode with vector sync using the Simple embedding provider as fallback.
## Testing
### Unit Tests
`tests/unit/test_config_validators.py` provides comprehensive coverage:
- Mode detection with priority ordering (7 tests)
- Single-user BasicAuth validation (8 tests)
- Multi-user BasicAuth validation (7 tests)
- OAuth single-audience validation (6 tests)
- OAuth token exchange validation (3 tests)
- Smithery validation (4 tests)
- Mode summary generation (3 tests)
- Edge cases (3 tests)
**Total: 41 tests, all passing**
### Integration Tests
Integration tests verify that:
- Each mode starts successfully with valid configuration
- Invalid configurations fail with clear error messages
- Existing deployments continue to work
## References
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-progressive-consent.md)
- [ADR-015: Unified Provider Architecture](ADR-015-unified-provider-architecture.md)
- [ADR-019: Multi-user BasicAuth Pass-Through](ADR-019-multi-user-basicauth-passthrough.md)
- Implementation: `nextcloud_mcp_server/config_validators.py`
- Tests: `tests/unit/test_config_validators.py`
+391
View File
@@ -0,0 +1,391 @@
# ADR-021: Configuration Consolidation and Simplification
**Status:** Accepted
**Date:** 2025-12-21
**Deciders:** Development Team
**Related:** ADR-020 (Deployment Modes), ADR-002 (Vector Sync), ADR-004 (Progressive Consent)
## Context
The configuration system has grown complex with overlapping concerns that make it difficult for users to switch between deployment modes and understand configuration dependencies.
### Problems Identified
1. **Confusing variable names don't reflect purpose**:
- `ENABLE_OFFLINE_ACCESS` - Actually controls refresh token storage for background operations, not general "offline" capabilities
- `VECTOR_SYNC_ENABLED` - Controls semantic search background indexing (implementation detail, not user-facing feature name)
- Users struggle to understand what these variables actually control
2. **Redundant configuration requirements**:
- Multi-user semantic search requires setting BOTH `ENABLE_OFFLINE_ACCESS=true` AND `VECTOR_SYNC_ENABLED=true`
- The dependency is one-way (semantic search needs background ops, but background ops don't need semantic search)
- Users must understand internal implementation details to configure a user-facing feature
3. **Implicit mode detection creates ambiguity**:
- Five deployment modes detected via priority-based logic
- Users can't easily predict which mode will activate
- Configuration errors don't clearly indicate which mode triggered the requirement
4. **OIDC_CLIENT_ID vs NEXTCLOUD_OIDC_CLIENT_ID confusion**:
- Investigation revealed these are NOT actually overlapping (`OIDC_CLIENT_ID` is test-only)
- However, their similar names create confusion
### Current Configuration Complexity
**Example: Multi-user OAuth with semantic search**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Why is this needed?
VECTOR_SYNC_ENABLED=true # And this separately?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
Users must understand:
- Semantic search requires background token storage (ENABLE_OFFLINE_ACCESS)
- Background token storage requires encryption keys
- The relationship between ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED
- Which deployment mode these settings will activate
## Decision
We consolidate overlapping functionality and add explicit mode selection while maintaining 100% backward compatibility.
### 1. Automatic Dependency Resolution
**Make ENABLE_SEMANTIC_SEARCH the primary control** that automatically enables required dependencies:
**New behavior**:
```python
@property
def enable_background_operations(self) -> bool:
"""Background operations - auto-enabled by semantic search in multi-user modes."""
# Check new names first
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
# Fall back to old name with deprecation warning
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
# Auto-enable if semantic search needs it
auto_enabled = self.enable_semantic_search and self.is_multi_user_mode()
return explicit or legacy or auto_enabled
@property
def enable_semantic_search(self) -> bool:
"""Semantic search - renamed from VECTOR_SYNC_ENABLED."""
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
return new_value or old_value
```
**Result**: Users set `ENABLE_SEMANTIC_SEARCH=true` and the system automatically enables background token storage when needed.
### 2. Explicit Mode Selection (Optional)
Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
```bash
# Optional: Explicitly declare deployment mode
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Valid values: single_user_basic, multi_user_basic,
# oauth_single_audience, oauth_token_exchange, smithery
```
**Detection logic**:
1. If `MCP_DEPLOYMENT_MODE` is set → validate and use it
2. Otherwise → use priority-based auto-detection (existing behavior)
3. Validate explicit mode doesn't conflict with detected mode
### 3. Simplified User Experience
**Before**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Confusing
VECTOR_SYNC_ENABLED=true # Why both?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**Benefits**:
- 2 fewer variables to understand/set
- Clear intent ("I want semantic search")
- Explicit mode declaration (optional)
- All existing configs continue working
### 4. Variable Naming Strategy
**Deprecated (but still functional)**:
- `ENABLE_OFFLINE_ACCESS` → Renamed to `ENABLE_BACKGROUND_OPERATIONS`
- `VECTOR_SYNC_ENABLED` → Renamed to `ENABLE_SEMANTIC_SEARCH`
**No change needed**:
- `VECTOR_SYNC_SCAN_INTERVAL` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Implementation tuning parameter (keep as-is)
**Rationale**: Only rename user-facing feature flags, not internal tuning parameters.
### 5. Backward Compatibility
**Support both old and new names for minimum 2 major versions**:
```python
@property
def enable_semantic_search(self) -> bool:
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
if new_value and old_value:
logger.warning(
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
"Using ENABLE_SEMANTIC_SEARCH. VECTOR_SYNC_ENABLED is deprecated."
)
if old_value and not new_value:
logger.warning(
"VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead."
)
return new_value or old_value
```
**Deprecation timeline**:
- v0.6.0: Add new variables, deprecate old ones (both work with warnings)
- v1.0.0: Remove old variables (breaking change, well-announced)
- Minimum 2 major versions of support (12+ months)
## Consequences
### Positive
1. **Reduced cognitive load**: Users set `ENABLE_SEMANTIC_SEARCH=true` instead of understanding internal dependencies
2. **Clearer intent**: Variable names reflect user-facing features, not implementation details
3. **Explicit mode control**: `MCP_DEPLOYMENT_MODE` removes detection ambiguity
4. **Better onboarding**: New users see simpler configuration in env.sample
5. **Improved error messages**: Validation can suggest "set MCP_DEPLOYMENT_MODE=X" instead of relying on implicit detection
6. **No breaking changes**: All existing configurations continue working
### Negative
1. **Transition period complexity**: Both old and new names supported for 2+ versions
2. **Documentation burden**: All docs must be updated to show new approach
3. **Test coverage expansion**: Must test both old and new variable names in all modes
4. **Migration effort**: Existing deployments should eventually migrate (optional but recommended)
### Neutral
1. **Same functionality**: No new features, just better organization
2. **Same validation**: Underlying requirements unchanged (e.g., semantic search still needs Qdrant)
3. **Same performance**: No runtime performance impact
## Implementation
### Phase 1: Configuration Consolidation (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add property-based deprecation with auto-enablement
- `nextcloud_mcp_server/config_validators.py` - Simplify validation (semantic search no longer requires explicit background operations setting)
- `nextcloud_mcp_server/app.py` - Add informative logging for auto-enablement
- `tests/unit/test_config_validators.py` - Add auto-enablement tests
- `docs/configuration-migration-v2.md` - Create migration guide
**Key changes**:
1. `enable_background_operations` property auto-enables when `enable_semantic_search=true` in multi-user modes
2. `enable_semantic_search` property accepts both `ENABLE_SEMANTIC_SEARCH` and `VECTOR_SYNC_ENABLED`
3. Smart logging when auto-enablement occurs or deprecated variables used
4. Validation simplified to remove redundant requirements
### Phase 2: Explicit Mode Selection (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add `deployment_mode` field
- `nextcloud_mcp_server/config_validators.py` - Check explicit mode first, fall back to auto-detection
- `tests/unit/test_config_validators.py` - Test mode override and conflict detection
- `docs/configuration.md` - Document mode selection
**Key changes**:
1. Add `MCP_DEPLOYMENT_MODE` environment variable (optional)
2. Mode detection checks explicit mode first, then auto-detects
3. Validate explicit mode doesn't conflict with detected mode
4. Better error messages referencing explicit mode setting
### Phase 3: env.sample Reorganization (v0.6.0)
**Files to create/modify**:
- `env.sample` - Reorganize by deployment mode
- `env.sample.single-user` - Simplest config template
- `env.sample.oauth-multi-user` - Multi-user template showing consolidation
- `env.sample.oauth-advanced` - Token exchange mode template
- `README.md` - Update Quick Start to reference templates
**Key changes**:
1. Group related settings by deployment mode
2. Show simplified configuration (only essential variables)
3. Document automatic dependencies inline
4. Provide mode-specific quick-start templates
### Phase 4: Documentation Updates (v0.7.0)
**Files to modify**:
- `docs/configuration.md` - Lead with consolidated approach
- `docs/authentication.md` - Update mode guidance with `MCP_DEPLOYMENT_MODE`
- `docs/troubleshooting.md` - Add consolidation troubleshooting section
- `docs/configuration-migration-v2.md` - Expand with comprehensive examples
- `docs/ADR-020-deployment-modes-and-configuration-validation.md` - Update configuration matrix
- All other ADRs - Update variable references
**Key changes**:
1. Update all examples to use new variable names
2. Add before/after migration examples
3. Document automatic dependency resolution
4. Add mode selection decision tree diagram
## Validation Strategy
### Test Coverage Requirements
**Backward compatibility tests**:
- Old variable names still work (ENABLE_OFFLINE_ACCESS, VECTOR_SYNC_ENABLED)
- New variable names work (ENABLE_BACKGROUND_OPERATIONS, ENABLE_SEMANTIC_SEARCH)
- Setting both old and new triggers deprecation warning but works correctly
- All 41 existing config validation tests pass
**Auto-enablement tests**:
- `ENABLE_SEMANTIC_SEARCH=true` in OAuth mode → `enable_background_operations=true`
- `ENABLE_SEMANTIC_SEARCH=true` in single-user mode → `enable_background_operations=false` (not needed)
- `ENABLE_SEMANTIC_SEARCH=false``enable_background_operations=false` (unless explicitly set)
**Mode selection tests**:
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
## Success Metrics
**Immediate** (v0.6.0 release):
- Zero breaking changes in existing deployments
- All 41 config validation tests pass
- New users report clearer configuration process
**Medium-term** (6 months after v0.6.0):
- 80% of new deployments use new variable names
- Mode selection errors decrease by 50%
- Support requests about configuration decrease
**Long-term** (12+ months):
- 90% of deployments migrated to new names
- Old variable names can be safely removed in v1.0.0
- Configuration-related issues in issue tracker decrease
## Alternatives Considered
### Alternative 1: Just Rename Variables
**Rejected**: User feedback: "There's no reason to just rename variables without consolidating functionality"
This would make names clearer but wouldn't reduce the number of variables users need to set. The real problem is requiring users to set both ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED when they just want semantic search.
### Alternative 2: Remove ENABLE_OFFLINE_ACCESS Entirely
**Rejected**: Advanced users need background operations without semantic search
Some deployments might want background token storage for future features (background Deck sync, background Calendar sync, etc.) without enabling semantic search. Keeping ENABLE_BACKGROUND_OPERATIONS (renamed) allows this.
### Alternative 3: Always Auto-Enable Background Operations
**Rejected**: Single-user mode doesn't need background token storage
Auto-enablement is only needed in multi-user modes. Single-user mode uses a shared client with BasicAuth, so background token storage is unnecessary. Always enabling it would waste resources and create confusing log messages.
### Alternative 4: Require All New Names Immediately
**Rejected**: Breaking change would affect all existing deployments
Forcing migration to new variable names in v0.6.0 would break every existing deployment. Supporting both old and new names with deprecation warnings provides a smooth migration path.
## References
- [ADR-020: Deployment Modes and Configuration Validation](ADR-020-deployment-modes-and-configuration-validation.md)
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md)
- [Issue: Configuration complexity for multi-user semantic search](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/XXX)
## Migration Examples
### Example 1: Single-User BasicAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
ENABLE_SEMANTIC_SEARCH=true # Renamed
QDRANT_LOCATION=:memory:
# Note: Background operations NOT auto-enabled (not needed in single-user mode)
```
### Example 2: Multi-User OAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
```
**After** (simplified):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
# Note: ENABLE_OFFLINE_ACCESS no longer needed (auto-enabled)
```
### Example 3: Multi-User OAuth WITHOUT Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # For future background features
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
# ADR-023: OAuth Authorization Server Proxy
## Status
Accepted
## Date
2026-03-02
## Context
When the MCP server operates in OAuth mode (e.g., `mcp-login-flow` profile), MCP clients like Claude Code need to authenticate before calling any tools. The server advertises itself as an OAuth Protected Resource via RFC 9728 (Protected Resource Metadata / PRM), which tells clients where to find the Authorization Server.
### The Problem
The original design used a **pass-through** pattern for Flow 1 (client authentication):
1. PRM at `/.well-known/oauth-protected-resource` pointed `authorization_servers` to Nextcloud's public URL
2. Claude Code performed OIDC discovery on Nextcloud, used DCR to register its own client, and obtained tokens directly from Nextcloud
3. Tokens issued by Nextcloud had Claude Code's `client_id` as the `aud` (audience) claim
This caused an audience mismatch:
```
Token rejected: Missing MCP audience.
Got klehQp8uHCK9fu... (Claude Code's client_id),
need 8ilzB5ZPWr2Qt4... (MCP server's client_id) or http://localhost:8004
```
The `_has_mcp_audience()` check in `unified_verifier.py` correctly requires tokens to contain either the MCP server's `client_id` or its URL as the audience — but tokens obtained directly from Nextcloud by a third-party client will never have that audience.
This meant Claude Code could never authenticate → could never call `nc_auth_provision_access` → Login Flow v2 never triggered → the server was unusable.
### Why Not Just Relax Audience Validation?
Audience validation exists for security (RFC 7519 §4.1.3). Removing it would allow any valid Nextcloud token to access the MCP server, including tokens issued for completely different purposes.
## Decision
Make the MCP server act as its own **OAuth Authorization Server proxy** (intermediary pattern). The MCP server advertises itself as the AS, handles client registration and authorization, but proxies the actual authentication to Nextcloud using its own credentials. This ensures all tokens have the correct audience.
### Flow Overview
```
Client MCP Server (AS Proxy) Nextcloud (IdP)
| | |
|-- POST /oauth/register ----->| ---- proxy DCR --------------->|
|<---- client_id, etc. --------|<---- client_id, etc. ----------|
| | |
|-- GET /oauth/authorize ----->| (store client params) |
| (client_id, redirect, | redirect with MCP's client_id |
| code_challenge, state) |------- GET /authorize -------->|
| | (MCP client_id, MCP callback) |
| | |
| | [user authenticates] |
| | |
| |<------ code + state -----------|
| | (exchange code server-side) |
| |------- POST /token ----------->|
| | (code, MCP client_id+secret) |
| |<------ NC token (aud=MCP) -----|
| | |
| | (generate proxy_code, store |
| | mapping to NC token) |
|<-- redirect to client -------| |
| (proxy_code, state) | |
| | |
|-- POST /oauth/token -------->| (verify PKCE, lookup code) |
| (proxy_code, code_verifier) | return stored NC token |
|<---- access_token -----------| |
| | |
|-- POST /mcp (Bearer token) ->| verify_access_token() |
| (NC token with aud=MCP ✓) | _has_mcp_audience() → PASS |
```
### Key Design Decisions
#### 1. PKCE Handling — Local Verification
The MCP server receives the client's `code_challenge` but does **not** forward it to Nextcloud. Instead:
- **Nextcloud side**: MCP server authenticates as a confidential client (`client_id` + `client_secret`), so PKCE is not required
- **Client side**: MCP server verifies PKCE locally when the client exchanges the proxy code at `/oauth/token`
This avoids the impossible situation where the server would need the `code_verifier` to exchange code with Nextcloud but doesn't have it (only the client does).
#### 2. In-Memory Proxy Code Storage
Proxy codes (the authorization codes issued by the AS proxy to clients) use in-memory storage rather than SQLite because:
- They have a 60-second TTL
- They are single-use (deleted on exchange)
- They only exist during the brief OAuth flow
- The MCP server is single-instance
#### 3. PRM Points to MCP Server
The `authorization_servers` field in the PRM response now points to the MCP server URL instead of Nextcloud's public URL. This is what triggers the entire proxy flow — clients discover the MCP server as their AS.
#### 4. DCR Proxy
Client registration requests at `/oauth/register` are proxied to Nextcloud's DCR endpoint. The resulting `client_id` is stored in the local `ClientRegistry` so that `/oauth/authorize` can validate it. The client receives the same DCR response it would get from Nextcloud directly.
## Alternatives Considered
### 1. Relax Audience Validation
Remove `_has_mcp_audience()` check entirely. **Rejected**: Violates RFC 7519 security model.
### 2. Client Pre-Registration
Require clients to register directly with Nextcloud and configure the MCP server with their `client_id`. **Rejected**: Poor UX, doesn't work with DCR-based clients like Claude Code.
### 3. Token Exchange (RFC 8693)
The MCP server could accept any Nextcloud token and exchange it for one with the correct audience. **Rejected**: Nextcloud's OIDC app doesn't support RFC 8693 token exchange. This was already explored in ADR-005.
### 4. Custom Audience Configuration
Add configuration to accept specific external `client_id` values as valid audiences. **Rejected**: Requires manual configuration per client, doesn't scale with DCR.
## New Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/.well-known/oauth-authorization-server` | GET | RFC 8414 AS metadata |
| `/oauth/authorize` | GET | Authorization (modified: intermediary, not pass-through) |
| `/oauth/token` | POST | Token exchange (proxy codes + refresh token proxy) |
| `/oauth/register` | POST | DCR proxy to Nextcloud |
## Files Modified
| File | Changes |
|------|---------|
| `nextcloud_mcp_server/auth/oauth_routes.py` | New: `oauth_as_metadata`, `oauth_register_proxy`, `oauth_token_endpoint`, `_oauth_callback_as_proxy`. Modified: `oauth_authorize` (intermediary pattern), `oauth_callback` (AS proxy routing) |
| `nextcloud_mcp_server/app.py` | New routes, PRM `authorization_servers` → MCP server URL, `app.state.supported_scopes` |
| `nextcloud_mcp_server/auth/client_registry.py` | New: `register_proxy_client()`, wildcard scope support |
## Consequences
### Positive
- Tokens always have the correct audience — `_has_mcp_audience()` passes
- Works with any MCP client that implements RFC 9728 (PRM) discovery
- No changes needed to Nextcloud's OIDC configuration
- DCR still works transparently (clients register via proxy)
- Existing Flow 2 (resource provisioning) and browser login are unaffected
### Negative
- MCP server is now stateful during the OAuth flow (in-memory proxy codes)
- Extra network hop for token exchange (MCP server → Nextcloud → back)
- Token refresh requires proxying through the MCP server
- Single-instance limitation for proxy code storage (acceptable for current deployment model)
### Risks
- In-memory proxy codes are lost on server restart (mitigated by 60s TTL — user just retries)
- Discovery endpoint fetch during OAuth flow adds latency (could be cached)
## References
- [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://tools.ietf.org/html/rfc8414)
- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://tools.ietf.org/html/rfc9728)
- [RFC 7636 — PKCE](https://tools.ietf.org/html/rfc7636)
- [RFC 7591 — Dynamic Client Registration](https://tools.ietf.org/html/rfc7591)
- ADR-004 — MCP Application OAuth (progressive consent architecture)
- ADR-005 — Token Audience Validation
+104
View File
@@ -0,0 +1,104 @@
# MCP 1.23.x DNS Rebinding Protection Fix
## Problem
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
### Root Cause
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
```python
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
```
### What Was Happening
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
### Why `--host 0.0.0.0` Didn't Help
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
## Solution
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
### Changes Made
Modified `nextcloud_mcp_server/app.py`:
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
2. **Updated all three FastMCP initializations**:
- OAuth mode (line 1015)
- Smithery stateless mode (line 1030)
- BasicAuth mode (line 1040)
Each now includes:
```python
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
```
## Impact
### ✅ What This Fixes
- **Kubernetes deployments**: Requests with k8s service DNS names now work
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
- **Ingress controllers**: Requests via ingress hostnames now work
### 🔒 Security Considerations
DNS rebinding protection defends against attacks where:
1. Attacker controls a DNS domain (e.g., `evil.com`)
2. DNS initially resolves to attacker's IP
3. After victim's browser caches the origin, DNS changes to victim's localhost
4. Attacker's page can now make requests to victim's localhost services
**Why it's safe to disable for this deployment:**
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
4. **Host header validation inappropriate** for multi-tenant k8s environments
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
```python
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[
"nextcloud-mcp-server.default.svc.cluster.local:*",
"mcp.example.com:*",
# Add all your expected Host header values
]
)
```
## Testing
- ✅ Ruff linting passes
- ✅ Type checking passes (pre-existing warnings unrelated)
- ✅ Module imports successfully
- ✅ Compatible with MCP 1.23.x
## References
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
- Issue #373 (original report of k8s breakage)
- PR #382 (MCP 1.23.x upgrade)
+422
View File
@@ -0,0 +1,422 @@
# Authentication Flows by Deployment Mode
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
## Quick Reference Matrix
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|------|-------------------|-----------------|-----------------|
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
## Communication Patterns
This document covers three distinct communication patterns:
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
---
## Deployment Modes
### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ (no auth required) │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ Authorization: Basic │
│ │ (embedded credentials) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
- Single shared `NextcloudClient` created at startup
- No MCP-level authentication required (server trusts local clients)
- All requests use the same Nextcloud user
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
#### Background Sync
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
**Implementation:** Background jobs use `get_settings()` to access credentials
#### Astrolabe Integration
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
---
### 2. Multi-User BasicAuth
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ Authorization: Basic │ │
│ (user credentials) │ │
│ │── BasicAuthMiddleware ────▶│
│ │ Extracts credentials │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (pass-through) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
- Credentials passed through to Nextcloud (not stored)
- Client created per-request from extracted credentials
- Stateless - no credential storage between requests
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
#### Background Sync (Optional)
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
```
Astrolabe MCP Server Nextcloud
│ │ │
│── Store App Password ──────▶│ │
│ (via management API) │ │
│ │── Store in SQLite ────────▶│
│ │ (encrypted) │
│◀── Confirmation ────────────│ │
│ │ │
│ [Background Job] │ │
│ │── Retrieve app password ──▶│
│ │ (from encrypted storage) │
│ │── HTTP + BasicAuth ───────▶│
│ │ (stored app password) │
│ │◀── API Response ───────────│
```
**Requirements:**
- `ENABLE_OFFLINE_ACCESS=true`
- `TOKEN_ENCRYPTION_KEY` for credential encryption
- `TOKEN_STORAGE_DB` for SQLite storage path
#### Astrolabe → MCP Server
```
Astrolabe MCP Server Nextcloud OIDC
│ │ │
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
│ (user initiates) │ │
│ │ │
│── Bearer Token ────────────▶│ │
│ (management API calls) │ │
│ │── Validate via JWKS ──────▶│
│ │ (or introspection) │
│◀── API Response ────────────│ │
```
**Key characteristics:**
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
- Authorization check: `token.sub == requested_resource_owner`
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
---
### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: ["mcp-server", │ │
│ "nextcloud"] │ │
│ │── Validate MCP audience ──▶│
│ │ (UnifiedTokenVerifier) │
│ │ │
│ │── HTTP + Same Token ──────▶│
│ │ Authorization: Bearer │
│ │ (multi-audience token) │
│ │ │
│ │ NC validates its own aud │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
- MCP server validates only MCP audience (per RFC 7519)
- Nextcloud independently validates its own audience
- No token exchange needed - same token used throughout
- Stateless operation for interactive requests
**Token validation flow:**
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
2. Token passed directly to Nextcloud via `get_client_from_context()`
3. Nextcloud validates its own audience when receiving API calls
**Implementation:**
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
- `context.py:96-99` - Uses token directly in multi-audience mode
#### Background Sync
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
```
MCP Server Nextcloud OIDC
│ │
[Background Job starts] │ │
│── Get refresh token ──────▶│
│ (from encrypted storage) │
│ │
│── Token refresh request ──▶│
│ grant_type=refresh_token │
│ scope=openid profile ... │
│◀── New access + refresh ───│
│ (rotation) │
│ │
│── Store rotated refresh ──▶│
│ (encrypted) │
│ │
│── HTTP + Access Token ────▶│
│ Authorization: Bearer │
│◀── API Response ───────────│
```
**Key characteristics:**
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
- `TokenBrokerService` handles token lifecycle
- Per-user locking prevents race conditions during concurrent refresh
**Implementation:**
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud OIDC
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: "mcp-server" │ │
│ (MCP audience only) │ │
│ │── Validate MCP audience ──▶│
│ │ │
│ │── RFC 8693 Exchange ──────▶│
│ │ grant_type= │
│ │ urn:ietf:params:oauth: │
│ │ grant-type:token-exchange
│ │ subject_token=<mcp-token>│
│ │ requested_audience= │
│ │ "nextcloud" │
│ │◀── Delegated Token ────────│
│ │ aud: "nextcloud" │
│ │ │
│ │── HTTP + Delegated Token ─▶│
│ │ Authorization: Bearer │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Strict audience separation: MCP token has `aud: "mcp-server"` only
- Server exchanges for Nextcloud-audience token on each request
- Ephemeral delegated tokens (not cached by default)
- Strongest security boundary between MCP and Nextcloud access
**Token exchange details:**
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
- Subject token: MCP access token
- Requested audience: Nextcloud resource URI
- Result: Short-lived token scoped for Nextcloud
**Implementation:**
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
- `context.py:88-94` - Routes to session client in exchange mode
#### Background Sync
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
```
MCP Server Nextcloud OIDC
│ │
[User provisions access] │ │
│── Flow 2 OAuth ───────────▶│
│ client_id="mcp-server" │
│ scope=offline_access ... │
│◀── Refresh Token ──────────│
│ (stored encrypted) │
│ │
[Background Job runs later] │ │
│── Refresh for background ─▶│
│ (same as single-audience)│
```
**Key difference from interactive:**
- Interactive: On-demand token exchange per request
- Background: Uses pre-provisioned refresh tokens (Flow 2)
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
Enabled by `SMITHERY_DEPLOYMENT=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── SSE Connect ─────────────▶│ │
│ ?nextcloud_url=... │ │
│ &username=... │ │
│ &app_password=... │ │
│ │── SmitheryConfigMiddleware │
│ │ Extract URL params │
│ │ │
│── MCP Request ─────────────▶│ │
│ (no Authorization header) │ │
│ │── Create per-request ─────▶│
│ │ NextcloudClient │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (from session params) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Configuration passed via URL query parameters (Smithery `configSchema`)
- No persistent state - client created fresh per request
- No OAuth infrastructure
- No background sync support (stateless)
- No admin UI available
**Required session parameters:**
- `nextcloud_url`: Nextcloud instance URL
- `username`: Nextcloud username
- `app_password`: Nextcloud app password
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
#### Background Sync
Not supported. Smithery mode is fully stateless with no credential storage.
#### Astrolabe Integration
Not applicable. Smithery deployments don't integrate with Astrolabe.
---
## Configuration Quick Reference
### Single-User BasicAuth
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
```
### Multi-User BasicAuth
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Single-Audience (Default)
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No username/password triggers OAuth mode
# Optional: Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Token Exchange
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### Smithery Stateless
```bash
SMITHERY_DEPLOYMENT=true
# All other config comes from session URL parameters
```
---
## Related Documentation
- [Authentication](authentication.md) - Configuration details and setup guides
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
+91
View File
@@ -140,6 +140,97 @@ Basic Authentication uses username and password credentials directly.
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
## Hybrid Authentication (Multi-User BasicAuth + OAuth)
When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the server operates in **hybrid authentication mode**. This provides the simplicity of BasicAuth for normal operations with the security of OAuth for administrative functions.
### Authentication Domains
**MCP Operations** (Tools, Resources):
- **Auth Method**: BasicAuth (HTTP Basic username/password)
- **Characteristics**:
- Stateless - no token storage
- Simple configuration
- Direct credential validation against Nextcloud
- Credentials passed per-request in Authorization header
- **Used For**: MCP tool calls from Claude, MCP client operations
**Management APIs** (Webhooks, Admin UI):
- **Auth Method**: OAuth bearer tokens
- **Characteristics**:
- Per-user authorization via OAuth consent flow
- Refresh tokens stored for background operations
- Token validation via UnifiedTokenVerifier
- Explicit user consent required
- **Used For**: Astrolabe admin UI, webhook management, vector sync operations
### Configuration
```env
# Enable multi-user BasicAuth
ENABLE_MULTI_USER_BASIC_AUTH=true
# Enable hybrid mode (OAuth provisioning for management APIs)
ENABLE_OFFLINE_ACCESS=true
# Enable background sync (required for hybrid mode currently)
VECTOR_SYNC_ENABLED=true
# Encryption key for refresh token storage
TOKEN_ENCRYPTION_KEY=<base64-encoded-key>
# Nextcloud connection
NEXTCLOUD_HOST=https://cloud.example.com
# OAuth credentials (optional - uses DCR if not set)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
### OAuth Provisioning Flow
1. Admin opens Astrolabe admin settings in Nextcloud
2. Clicks "Authorize" to enable webhook management
3. Redirected to `/oauth/authorize-nextcloud` on MCP server
4. MCP server redirects to Nextcloud OAuth consent page
5. Admin grants OAuth consent (scopes: `openid`, `profile`, `offline_access`)
6. Redirected back to `/oauth/callback` on MCP server
7. MCP server stores refresh token (encrypted)
8. Admin can now manage webhooks from Astrolabe UI
### Benefits
- **Simple MCP client setup**: Use BasicAuth (no OAuth complexity for end users)
- **Secure background operations**: Webhooks use per-user OAuth tokens (no shared credentials)
- **Explicit authorization**: Admins must explicitly grant OAuth consent for webhook operations
- **Per-user isolation**: Each admin's webhook operations use their own refresh token
### Trade-offs
- **Two auth systems**: More complex server configuration than pure BasicAuth or OAuth
- **OAuth setup required**: Admins must complete OAuth flow before managing webhooks
- **Token storage**: Requires database and encryption key for refresh tokens
### Comparison
| Feature | Pure BasicAuth | Hybrid Mode | Pure OAuth |
|---------|---------------|-------------|------------|
| MCP Operations | BasicAuth | BasicAuth | OAuth Bearer Token |
| Management API | N/A | OAuth Bearer Token | OAuth Bearer Token |
| Webhook Operations | N/A | OAuth Refresh Token | OAuth Refresh Token |
| MCP Client Setup | Simple | Simple | Complex (PKCE flow) |
| Admin UI Auth | N/A | OAuth Consent | OAuth Login |
| Token Storage | None | Refresh tokens only | All tokens |
| Deployment Complexity | Low | Medium | High |
### Astrolabe User Setup (Hybrid Mode)
For Astrolabe-specific user setup instructions in hybrid mode, see the [Astrolabe documentation](https://github.com/cbcoutinho/astrolabe/blob/master/docs/user-setup-hybrid-mode.md).
### See Also
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
## Mode Detection
The server automatically detects the authentication mode:
+338
View File
@@ -0,0 +1,338 @@
# Amazon Bedrock Setup Guide
This guide covers how to configure the Nextcloud MCP Server to use Amazon Bedrock for embeddings and text generation.
## Prerequisites
1. **AWS Account** with access to Amazon Bedrock
2. **boto3 library** installed: `pip install boto3` or `uv sync --group dev`
3. **Model Access** - Request access to models in AWS Bedrock console
## Required AWS Permissions
### IAM Policy for Bedrock Access
The AWS IAM user or role needs the following permissions:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockInvokeModels",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel",
"bedrock:InvokeModelWithResponseStream"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*"
]
}
]
}
```
### Minimal Permissions (Production)
For production deployments, restrict to specific models:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockEmbeddings",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel"
],
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/amazon.titan-embed-text-v2:0"
]
},
{
"Sid": "BedrockGeneration",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModel"
],
"Resource": [
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0"
]
}
]
}
```
### Additional Permissions (Optional)
For advanced use cases:
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "BedrockListModels",
"Effect": "Allow",
"Action": [
"bedrock:ListFoundationModels",
"bedrock:GetFoundationModel"
],
"Resource": "*"
},
{
"Sid": "BedrockAsyncInvoke",
"Effect": "Allow",
"Action": [
"bedrock:InvokeModelAsync",
"bedrock:GetAsyncInvoke",
"bedrock:ListAsyncInvokes"
],
"Resource": [
"arn:aws:bedrock:*::foundation-model/*"
]
}
]
}
```
## Model Access
Before using Bedrock models, you must request access in the AWS Console:
1. Navigate to **Amazon Bedrock****Model access**
2. Click **Manage model access**
3. Select models you want to use:
- **Embeddings:** Amazon Titan Embed Text, Cohere Embed
- **Text Generation:** Anthropic Claude, Meta Llama, Amazon Titan Text
4. Click **Request model access**
5. Wait for approval (usually instant for most models)
## Supported Models
### Embedding Models
| Provider | Model ID | Dimensions | Best For |
|----------|----------|------------|----------|
| Amazon Titan | `amazon.titan-embed-text-v1` | 1,536 | General purpose |
| Amazon Titan | `amazon.titan-embed-text-v2:0` | 1,024 | Latest, improved quality |
| Cohere | `cohere.embed-english-v3` | 1,024 | English text |
| Cohere | `cohere.embed-multilingual-v3` | 1,024 | Multilingual |
### Text Generation Models
| Provider | Model ID | Context | Best For |
|----------|----------|---------|----------|
| Anthropic | `anthropic.claude-3-sonnet-20240229-v1:0` | 200K | Balanced performance |
| Anthropic | `anthropic.claude-3-haiku-20240307-v1:0` | 200K | Fast, cost-effective |
| Anthropic | `anthropic.claude-3-opus-20240229-v1:0` | 200K | Highest quality |
| Meta | `meta.llama3-8b-instruct-v1:0` | 8K | Fast, open-source |
| Meta | `meta.llama3-70b-instruct-v1:0` | 8K | High quality |
| Amazon | `amazon.titan-text-express-v1` | 8K | Fast, low cost |
| Mistral | `mistral.mistral-7b-instruct-v0:2` | 32K | Efficient |
## Configuration
### Environment Variables
**Required:**
```bash
AWS_REGION=us-east-1
```
**Optional (at least one model required):**
```bash
# For embeddings
BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# For text generation (RAG evaluation)
BEDROCK_GENERATION_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
```
**AWS Credentials (choose one method):**
**Method 1: Environment Variables**
```bash
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
```
**Method 2: AWS Credentials File** (`~/.aws/credentials`)
```ini
[default]
aws_access_key_id = AKIAIOSFODNN7EXAMPLE
aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
```
**Method 3: IAM Role** (when running on AWS EC2/ECS/Lambda)
- No credentials needed, uses instance/task role automatically
### Docker Configuration
Add to your `docker-compose.yml`:
```yaml
services:
mcp:
environment:
- AWS_REGION=us-east-1
- BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
- BEDROCK_GENERATION_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
```
Or use AWS credentials file volume mount:
```yaml
services:
mcp:
volumes:
- ~/.aws:/root/.aws:ro
environment:
- AWS_REGION=us-east-1
- BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
```
## Usage Examples
### Embeddings Only
```bash
export AWS_REGION=us-east-1
export BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
export AWS_ACCESS_KEY_ID=your-key
export AWS_SECRET_ACCESS_KEY=your-secret
uv run nextcloud-mcp-server
```
### Both Embeddings and Generation
```bash
export AWS_REGION=us-east-1
export BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
export BEDROCK_GENERATION_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
# For RAG evaluation with Bedrock
export RAG_EVAL_PROVIDER=bedrock
export RAG_EVAL_BEDROCK_MODEL=anthropic.claude-3-sonnet-20240229-v1:0
uv run python -m tests.rag_evaluation.evaluate
```
### Programmatic Usage
```python
from nextcloud_mcp_server.providers import BedrockProvider
# Embeddings only
provider = BedrockProvider(
region_name="us-east-1",
embedding_model="amazon.titan-embed-text-v2:0",
)
embeddings = await provider.embed_batch(["text1", "text2"])
# Both capabilities
provider = BedrockProvider(
region_name="us-east-1",
embedding_model="amazon.titan-embed-text-v2:0",
generation_model="anthropic.claude-3-sonnet-20240229-v1:0",
)
# Generate embeddings
embedding = await provider.embed("query text")
# Generate text
response = await provider.generate("Write a summary", max_tokens=500)
```
## Cost Considerations
### Embedding Costs (as of Jan 2025)
| Model | Price per 1K tokens |
|-------|---------------------|
| Titan Embed Text v2 | $0.0001 |
| Cohere Embed English v3 | $0.0001 |
### Generation Costs (as of Jan 2025)
| Model | Input (per 1K tokens) | Output (per 1K tokens) |
|-------|----------------------|------------------------|
| Claude 3 Haiku | $0.00025 | $0.00125 |
| Claude 3 Sonnet | $0.003 | $0.015 |
| Claude 3 Opus | $0.015 | $0.075 |
| Llama 3 8B | $0.0003 | $0.0006 |
| Titan Text Express | $0.0002 | $0.0006 |
**Note:** Prices vary by region. Check [AWS Bedrock Pricing](https://aws.amazon.com/bedrock/pricing/) for current rates.
## Troubleshooting
### Error: "Executable doesn't exist" or boto3 not found
**Solution:**
```bash
uv sync --group dev # Installs boto3
```
### Error: "AccessDeniedException"
**Causes:**
1. IAM permissions missing
2. Model access not requested
3. Wrong AWS region
**Solution:**
1. Verify IAM policy includes `bedrock:InvokeModel`
2. Request model access in Bedrock console
3. Check model is available in your region
### Error: "ResourceNotFoundException"
**Cause:** Invalid model ID or model not available in region
**Solution:**
- Verify model ID matches exactly (case-sensitive)
- Check model availability in your AWS region
- Use `aws bedrock list-foundation-models` to see available models
### Error: "ThrottlingException"
**Cause:** Rate limit exceeded
**Solution:**
- Reduce request rate
- Request quota increase via AWS Support
- Use batch operations where possible
## Security Best Practices
1. **Use IAM Roles** when running on AWS infrastructure
2. **Rotate Access Keys** regularly if using IAM users
3. **Restrict Permissions** to only required models
4. **Enable CloudTrail** for audit logging
5. **Use AWS Secrets Manager** for credential management
6. **Monitor Costs** with AWS Cost Explorer and Budgets
## Regional Availability
Amazon Bedrock is available in:
- **US East (N. Virginia)**: `us-east-1` ✅ Most models
- **US West (Oregon)**: `us-west-2` ✅ Most models
- **Asia Pacific (Singapore)**: `ap-southeast-1`
- **Asia Pacific (Tokyo)**: `ap-northeast-1`
- **Europe (Frankfurt)**: `eu-central-1`
**Note:** Model availability varies by region. Check the [AWS Bedrock documentation](https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html) for current availability.
## References
- [AWS Bedrock Documentation](https://docs.aws.amazon.com/bedrock/)
- [AWS Bedrock Pricing](https://aws.amazon.com/bedrock/pricing/)
- [boto3 Bedrock Runtime API](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock-runtime.html)
- [Provider Architecture ADR](./ADR-015-unified-provider-architecture.md)
+564
View File
@@ -0,0 +1,564 @@
# Configuration Migration Guide v2
**Version:** v0.58.0
**Status:** Active
**Related ADR:** [ADR-021: Configuration Consolidation and Simplification](ADR-021-configuration-consolidation.md)
## Overview
This guide helps you migrate from the old configuration variables to the new consolidated approach introduced in v0.58.0.
**Key Changes:**
- `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
- `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
- New: `MCP_DEPLOYMENT_MODE` for explicit mode selection
- Automatic dependency resolution: semantic search auto-enables background operations
**Backward Compatibility:**
- Old variable names still work in v0.58.0+
- Deprecation warnings logged when old names used
- Old names will be removed in v1.0.0
---
## Quick Reference: Variable Name Changes
| Old Name | New Name | Status |
|----------|----------|--------|
| `VECTOR_SYNC_ENABLED` | `ENABLE_SEMANTIC_SEARCH` | Deprecated |
| `ENABLE_OFFLINE_ACCESS` | `ENABLE_BACKGROUND_OPERATIONS` | Deprecated |
| N/A (auto-detected) | `MCP_DEPLOYMENT_MODE` | New (optional) |
**Tuning parameters unchanged:**
- `VECTOR_SYNC_SCAN_INTERVAL` - Keep as-is
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Keep as-is
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Keep as-is
---
## Migration Scenarios
### Scenario 1: Single-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=single_user_basic
# Updated variable name
ENABLE_SEMANTIC_SEARCH=true # Previously VECTOR_SYNC_ENABLED
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional `MCP_DEPLOYMENT_MODE` for clarity
- ✅ Background operations NOT auto-enabled (not needed in single-user mode)
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=single_user_basic`
3. Restart server
4. Verify deprecation warnings are gone
---
### Scenario 2: Multi-User OAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Both variables required - confusing!
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# One variable does it all!
ENABLE_SEMANTIC_SEARCH=true # Automatically enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
# Background operations are auto-enabled by ENABLE_SEMANTIC_SEARCH
```
**What Changed:**
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
-`ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
4. Restart server
5. Check logs for confirmation: "Automatically enabled background operations for semantic search"
---
### Scenario 3: Multi-User OAuth WITHOUT Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Enable background operations for future features
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Renamed for clarity
ENABLE_BACKGROUND_OPERATIONS=true # Previously ENABLE_OFFLINE_ACCESS
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**What Changed:**
- ✅ Renamed `ENABLE_OFFLINE_ACCESS` to `ENABLE_BACKGROUND_OPERATIONS`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `ENABLE_OFFLINE_ACCESS=true` with `ENABLE_BACKGROUND_OPERATIONS=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
3. Restart server
---
### Scenario 4: Multi-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Both required - redundant
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=multi_user_basic
# One variable handles both!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
- ✅ Clearer variable naming
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=multi_user_basic`
4. Restart server
---
### Scenario 5: Token Exchange Mode with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Both required
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# One variable!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Explicit mode declaration available
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_token_exchange`
4. Restart server
---
## Understanding Automatic Dependency Resolution
### How It Works
In v0.58.0+, the server uses smart dependency resolution:
```python
# In multi-user modes (OAuth, Multi-User BasicAuth):
if ENABLE_SEMANTIC_SEARCH == true:
background_operations = automatically enabled
refresh_tokens = automatically requested
token_storage = required (TOKEN_ENCRYPTION_KEY, TOKEN_STORAGE_DB)
oauth_credentials = required (for app password retrieval)
```
**What this means:**
- ✅ Set `ENABLE_SEMANTIC_SEARCH=true`
- ✅ Provide required infrastructure (Qdrant, Ollama, encryption key)
- ✅ System automatically enables background operations
- ❌ No need to set `ENABLE_BACKGROUND_OPERATIONS` separately
### When Automatic Enablement Happens
| Deployment Mode | Semantic Search Enabled | Background Operations Auto-Enabled? |
|----------------|------------------------|-----------------------------------|
| Single-User BasicAuth | ✅ | ❌ No (not needed) |
| Multi-User BasicAuth | ✅ | ✅ Yes |
| OAuth Single-Audience | ✅ | ✅ Yes |
| OAuth Token Exchange | ✅ | ✅ Yes |
| Smithery Stateless | N/A (not supported) | N/A |
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
Only needed when you want background operations **without** semantic search:
```bash
# Example: OAuth mode with background operations but NO semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Explicitly enable background operations for future features
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search disabled
ENABLE_SEMANTIC_SEARCH=false
```
---
## Explicit Mode Selection
### Why Use MCP_DEPLOYMENT_MODE?
**Benefits:**
- ✅ Removes ambiguity about which mode is active
- ✅ Validation errors reference specific mode requirements
- ✅ Catches configuration mistakes early
- ✅ Self-documenting configuration
**Example:**
```bash
# Without explicit mode:
NEXTCLOUD_HOST=https://nextcloud.example.com
# Is this OAuth or Multi-User BasicAuth? Not immediately clear.
# With explicit mode:
MCP_DEPLOYMENT_MODE=oauth_single_audience
NEXTCLOUD_HOST=https://nextcloud.example.com
# Clear: This is OAuth mode
```
### Valid Mode Values
| Mode Value | Description |
|-----------|-------------|
| `single_user_basic` | Single-user with username/password |
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
| `oauth_single_audience` | Multi-user OAuth (recommended) |
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
| `smithery` | Smithery platform deployment |
### Mode Detection Priority
When `MCP_DEPLOYMENT_MODE` is set:
1. ✅ Explicit mode is used
2. ✅ Server validates configuration matches explicit mode
3. ❌ Auto-detection is skipped
When `MCP_DEPLOYMENT_MODE` is NOT set:
1. ✅ Auto-detection runs (existing behavior)
2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
---
## Validation and Error Messages
### Old Validation (v0.57.x)
```
Error: [multi_user_basic] ENABLE_OFFLINE_ACCESS is required when VECTOR_SYNC_ENABLED is enabled
```
**Problem:** User must understand internal dependency relationship
### New Validation (v0.58.0+)
```
Error: [multi_user_basic] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Benefit:** Clear what's needed, no mention of internal ENABLE_BACKGROUND_OPERATIONS flag
---
## Troubleshooting Migration
### Issue: Deprecation Warning After Migration
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Solution:**
1. Check for `VECTOR_SYNC_ENABLED` in `.env` file
2. Replace with `ENABLE_SEMANTIC_SEARCH`
3. Search for any scripts/CI configs using old name
4. Restart server
### Issue: Both Old and New Names Set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Solution:**
1. Remove `VECTOR_SYNC_ENABLED` from `.env`
2. Keep `ENABLE_SEMANTIC_SEARCH`
3. Restart server
### Issue: Missing Required Dependencies
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Solution:**
When semantic search is enabled in multi-user modes, you need:
- `TOKEN_ENCRYPTION_KEY` - Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- `TOKEN_STORAGE_DB` - Path to SQLite database (e.g., `/app/data/tokens.db`)
- `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` - For app password retrieval
### Issue: Unexpected Mode Detected
**Symptom:**
Server activates `oauth_single_audience` mode when you expected `multi_user_basic`
**Solution:**
Add explicit mode declaration:
```bash
MCP_DEPLOYMENT_MODE=multi_user_basic
ENABLE_MULTI_USER_BASIC_AUTH=true
```
---
## Testing Your Migration
### Step 1: Verify Configuration
```bash
# Set new variable names in .env
cat .env | grep -E "(ENABLE_SEMANTIC_SEARCH|ENABLE_BACKGROUND_OPERATIONS|MCP_DEPLOYMENT_MODE)"
```
### Step 2: Check for Old Variable Names
```bash
# Should return nothing after migration
cat .env | grep -E "(VECTOR_SYNC_ENABLED|ENABLE_OFFLINE_ACCESS)"
```
### Step 3: Start Server and Check Logs
```bash
# Start server
docker-compose up mcp
# Look for:
# 1. No deprecation warnings
# 2. Correct mode detected
# 3. Auto-enablement messages (if using semantic search in multi-user mode)
```
**Expected Log Output (Multi-User OAuth + Semantic Search):**
```
INFO: Using explicit deployment mode: oauth_single_audience
INFO: Automatically enabled background operations for semantic search in multi-user mode.
INFO: Vector sync enabled. Starting background scanner...
```
### Step 4: Verify Functionality
Test that existing features still work:
- [ ] Semantic search returns results
- [ ] Background indexing runs
- [ ] OAuth flow completes successfully
- [ ] Refresh tokens are stored/retrieved
---
## Quick Start Templates
We provide mode-specific templates for new deployments:
| Template | Use Case |
|----------|----------|
| `env.sample.single-user` | Simplest setup |
| `env.sample.oauth-multi-user` | Recommended multi-user |
| `env.sample.oauth-advanced` | Token exchange mode |
**Usage:**
```bash
cp env.sample.oauth-multi-user .env
# Edit .env with your values
docker-compose up -d
```
---
## Timeline and Support
| Version | Status | Old Variable Support |
|---------|--------|---------------------|
| v0.57.x | Stable | Old names only |
| v0.58.0 | Current | Both old and new (with warnings) |
| v1.0.0 | Breaking | New names only |
**Recommendation:** Migrate before v1.0.0 (12+ months minimum)
---
## Getting Help
If you encounter issues during migration:
1. **Check the logs** - Look for deprecation warnings and error messages
2. **Review ADR-021** - See [docs/ADR-021-configuration-consolidation.md](ADR-021-configuration-consolidation.md)
3. **Use mode-specific templates** - See `env.sample.*` files
4. **File an issue** - Include your `.env` (redacted), logs, and mode
---
## Summary
**What You Need to Do:**
1. ✅ Rename `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
2. ✅ (Optional) Rename `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
3. ✅ (Recommended) Add `MCP_DEPLOYMENT_MODE` for clarity
4. ✅ Remove redundant settings (semantic search auto-enables background ops in multi-user modes)
5. ✅ Test your configuration
**What the Server Does Automatically:**
- ✅ Supports both old and new variable names
- ✅ Logs deprecation warnings for old names
- ✅ Auto-enables background operations when semantic search is enabled in multi-user modes
- ✅ Validates configuration and provides clear error messages
**Migration Timeline:**
- Now → v1.0.0: Both old and new names work
- v1.0.0+: Only new names supported
**Questions?** See [docs/configuration.md](configuration.md) or file an issue.
+181 -15
View File
@@ -2,25 +2,82 @@
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
> **Note:** Configuration was significantly simplified in v0.58.0. If you're upgrading from v0.57.x, see the [Configuration Migration Guide](configuration-migration-v2.md).
## Quick Start
Create a `.env` file based on `env.sample`:
We provide mode-specific configuration templates for quick setup:
```bash
# Choose a template based on your deployment mode:
cp env.sample.single-user .env # Simplest - one user, local dev
cp env.sample.oauth-multi-user .env # Recommended - multi-user OAuth
cp env.sample.oauth-advanced .env # Advanced - token exchange mode
# Or start from the full example:
cp env.sample .env
# Edit .env with your Nextcloud details
```
Then choose your authentication mode:
Then choose your deployment mode:
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
- [Basic Authentication Configuration](#basic-authentication-legacy)
- [Single-User BasicAuth](#single-user-basicauth-mode) - Simplest for personal instances
- [Multi-User OAuth](#multi-user-oauth-modes) - Recommended for production
- [Deployment Mode Selection](#deployment-mode-selection) - Explicit mode declaration
---
## OAuth2/OIDC Configuration
## Deployment Mode Selection
OAuth2/OIDC is the recommended authentication mode for production deployments.
**New in v0.58.0:** You can explicitly declare your deployment mode to remove ambiguity and catch configuration errors early.
```dotenv
# Optional but recommended
MCP_DEPLOYMENT_MODE=oauth_single_audience
```
**Valid values:**
- `single_user_basic` - Single-user with username/password
- `multi_user_basic` - Multi-user with BasicAuth pass-through
- `oauth_single_audience` - Multi-user OAuth (recommended)
- `oauth_token_exchange` - Multi-user OAuth with token exchange
- `smithery` - Smithery platform deployment
**Benefits:**
- ✅ Clear which mode is active
- ✅ Better validation error messages
- ✅ Self-documenting configuration
- ✅ Catches configuration mistakes early
**Auto-detection:** If `MCP_DEPLOYMENT_MODE` is not set, the server auto-detects the mode based on other settings (existing behavior).
See [Authentication Modes](authentication.md) for detailed comparison of deployment modes.
---
## Single-User BasicAuth Mode
BasicAuth with a single user is the simplest deployment mode. Use for personal instances, local development, and testing.
```dotenv
# Minimal single-user configuration
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=single_user_basic
```
> [!WARNING]
> **Security Notice:** BasicAuth stores credentials in environment variables and is less secure than OAuth. Use OAuth for production multi-user deployments.
---
## Multi-User OAuth Modes
OAuth2/OIDC is the recommended authentication mode for production multi-user deployments.
### Minimal Configuration (Auto-registration)
@@ -28,6 +85,9 @@ OAuth2/OIDC is the recommended authentication mode for production deployments.
# .env file for OAuth with auto-registration
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
@@ -41,6 +101,9 @@ This minimal configuration uses dynamic client registration to automatically reg
# .env file for OAuth with pre-configured client
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# OAuth Client Credentials (optional - auto-registers if not provided)
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
@@ -108,10 +171,104 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
---
## SSL/TLS Configuration (Optional)
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
### Custom CA Bundle (Recommended)
Point the server at your CA certificate file:
```dotenv
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
```
With Docker, mount the certificate as a read-only volume:
```bash
docker run \
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
-e NEXTCLOUD_HOST=https://nextcloud.local \
--env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Disable Verification (Development Only)
> [!WARNING]
> Disabling TLS verification is insecure. Only use this for local development or testing.
```dotenv
NEXTCLOUD_VERIFY_SSL=false
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
### Scope
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
- OIDC discovery and token endpoints
- OAuth client registration (DCR)
- Health checks
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
---
## Semantic Search Configuration (Optional)
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
### Quick Start
**Single-User Mode:**
```dotenv
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Enable semantic search
ENABLE_SEMANTIC_SEARCH=true
# Vector database
QDRANT_LOCATION=:memory:
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
**Multi-User OAuth Mode:**
```dotenv
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Enable semantic search
# In multi-user modes, this AUTOMATICALLY enables background operations!
ENABLE_SEMANTIC_SEARCH=true
# Required for background operations (auto-enabled by semantic search)
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Vector database
QDRANT_URL=http://qdrant:6333
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
> **Note:** In multi-user modes (OAuth, Multi-User BasicAuth), enabling `ENABLE_SEMANTIC_SEARCH` automatically enables background operations and refresh token storage. You don't need to set `ENABLE_BACKGROUND_OPERATIONS` separately!
### Qdrant Vector Database Modes
The server supports three Qdrant deployment modes:
@@ -126,7 +283,7 @@ No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, t
```dotenv
# No Qdrant configuration needed - defaults to :memory:
VECTOR_SYNC_ENABLED=true
ENABLE_SEMANTIC_SEARCH=true
```
**Pros:**
@@ -145,7 +302,7 @@ For single-instance deployments that need persistence without a separate Qdrant
```dotenv
# Local persistent storage
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
VECTOR_SYNC_ENABLED=true
ENABLE_SEMANTIC_SEARCH=true
```
**Pros:**
@@ -166,7 +323,7 @@ For production deployments with a dedicated Qdrant service:
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=your-secret-api-key # Optional
QDRANT_COLLECTION=nextcloud_content # Optional
VECTOR_SYNC_ENABLED=true
ENABLE_SEMANTIC_SEARCH=true
```
**Pros:**
@@ -283,13 +440,15 @@ Solutions:
- Data corruption in Qdrant
- Confusing error messages during indexing
### Vector Sync Configuration
### Background Indexing Configuration
Control background indexing behavior:
```dotenv
# Vector sync settings (ADR-007)
VECTOR_SYNC_ENABLED=true # Enable background indexing
# Semantic search (ADR-007, ADR-021)
ENABLE_SEMANTIC_SEARCH=true # Enable background indexing
# Tuning parameters (advanced - only modify if needed)
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
@@ -299,6 +458,8 @@ DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
```
> **Note:** The `VECTOR_SYNC_*` tuning parameters keep their names as they're implementation details. Only the user-facing feature flag was renamed to `ENABLE_SEMANTIC_SEARCH`.
### Embedding Service Configuration
The server uses an embedding service to generate vector representations. Two options are available:
@@ -369,11 +530,11 @@ DOCUMENT_CHUNK_OVERLAP=100
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ENABLE_SEMANTIC_SEARCH` | ⚠️ Optional | `false` | Enable semantic search with background indexing (replaces `VECTOR_SYNC_ENABLED`) |
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name |
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
@@ -383,6 +544,9 @@ DOCUMENT_CHUNK_OVERLAP=100
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
**Deprecated variables (still functional):**
- `VECTOR_SYNC_ENABLED` - Use `ENABLE_SEMANTIC_SEARCH` instead (will be removed in v1.0.0)
### Docker Compose Example
Enable network mode Qdrant with docker-compose:
@@ -392,7 +556,7 @@ services:
mcp:
environment:
- QDRANT_URL=http://qdrant:6333
- VECTOR_SYNC_ENABLED=true
- ENABLE_SEMANTIC_SEARCH=true
qdrant:
image: qdrant/qdrant:latest
@@ -545,6 +709,7 @@ uv run nextcloud-mcp-server --no-oauth \
## See Also
- [Configuration Migration Guide v2](configuration-migration-v2.md) - **New in v0.58.0:** Migrate from old variable names
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
@@ -553,3 +718,4 @@ uv run nextcloud-mcp-server --no-oauth \
- [Running the Server](running.md) - Starting the server with different configurations
- [Troubleshooting](troubleshooting.md) - Common configuration issues
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
- [ADR-021](ADR-021-configuration-consolidation.md) - Configuration consolidation architecture decision
+301
View File
@@ -0,0 +1,301 @@
# Database Migrations
This document describes the database migration system for nextcloud-mcp-server's token storage database.
## Overview
The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides:
- **Version Control**: Track schema changes in Git
- **Rollback Support**: Safely downgrade schema if needed
- **Audit Trail**: Migration files serve as schema changelog
- **Automated Upgrades**: Database schema updates automatically on startup
## Architecture
### Migration Strategy
The system handles three scenarios:
1. **New Database**: Runs migrations from scratch to create all tables
2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes)
3. **Alembic-Managed Database**: Upgrades to latest version automatically
### Directory Structure
```
nextcloud-mcp-server/
├── alembic/ # Alembic migrations
│ ├── versions/ # Migration scripts
│ │ └── 20251217_2200_001_initial_schema.py
│ ├── env.py # Alembic environment
│ ├── script.py.mako # Migration template
│ └── README # Migration usage guide
├── alembic.ini # Alembic configuration
└── nextcloud_mcp_server/
├── auth/storage.py # Uses migrations on init
└── migrations.py # Migration utilities
```
## Usage
### Automatic Migration on Startup
Migrations run automatically when the server starts:
```bash
uv run nextcloud-mcp-server
```
The `RefreshTokenStorage.initialize()` method:
1. Checks if database is Alembic-managed
2. Stamps pre-Alembic databases with initial revision
3. Upgrades to latest version
### Manual Migration Commands
```bash
# Show current database version
uv run nextcloud-mcp-server db current
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show migration history
uv run nextcloud-mcp-server db history
# Downgrade by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
# Specify custom database path
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
```
### Environment Variables
- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`)
## Creating Migrations (Developers)
### Step 1: Create Migration File
```bash
uv run nextcloud-mcp-server db migrate "add user preferences table"
```
This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions.
### Step 2: Write Migration SQL
Since we don't use SQLAlchemy models, write raw SQL:
```python
def upgrade() -> None:
"""Add user preferences table."""
op.execute("""
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT DEFAULT 'light',
language TEXT DEFAULT 'en',
created_at INTEGER NOT NULL
)
""")
op.execute("""
CREATE INDEX idx_user_preferences_user_id
ON user_preferences(user_id)
""")
def downgrade() -> None:
"""Remove user preferences table."""
op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id")
op.execute("DROP TABLE IF EXISTS user_preferences")
```
### Step 3: Test Migration
```bash
# Test upgrade
uv run nextcloud-mcp-server db upgrade -d /tmp/test.db
# Verify schema
sqlite3 /tmp/test.db ".schema"
# Test downgrade
uv run nextcloud-mcp-server db downgrade -d /tmp/test.db
# Verify removal
sqlite3 /tmp/test.db ".schema"
```
### Step 4: Commit Migration
```bash
git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py
git commit -m "feat: add user preferences table migration"
```
## SQLite Limitations
SQLite has limited `ALTER TABLE` support:
### Supported Operations
- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...`
- ✅ Rename table: `ALTER TABLE old RENAME TO new`
- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+)
### Unsupported Operations (Requires Table Recreation)
- ❌ Drop column
- ❌ Change column type
- ❌ Add constraints to existing columns
### Table Recreation Pattern
For complex schema changes:
```python
def upgrade() -> None:
# Create new table with desired schema
op.execute("""
CREATE TABLE refresh_tokens_new (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
new_field TEXT, -- New column
expires_at INTEGER,
created_at INTEGER NOT NULL
)
""")
# Copy data from old table
op.execute("""
INSERT INTO refresh_tokens_new
(user_id, encrypted_token, expires_at, created_at)
SELECT user_id, encrypted_token, expires_at, created_at
FROM refresh_tokens
""")
# Drop old table and rename new table
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
# Recreate indexes
op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)")
```
## Best Practices
### Naming Conventions
- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py`
- **Revision IDs**: Sequential numbers (`001`, `002`, `003`)
- **Descriptions**: Imperative mood ("add table", "remove column")
### Migration Guidelines
1. **Test Thoroughly**: Test both upgrade and downgrade paths
2. **Preserve Data**: Ensure data migration logic is correct
3. **Document Changes**: Add comments explaining complex operations
4. **Small Changes**: One logical change per migration
5. **No Breaking Changes**: Maintain backward compatibility when possible
### Downgrade Considerations
- **Data Loss**: Downgrade may lose data (dropped columns, tables)
- **Confirmation**: Downgrade command requires explicit confirmation
- **Testing**: Always test downgrade path before deploying
- **Emergency Only**: Use downgrades only for critical rollbacks
## Backward Compatibility
### Pre-Alembic Databases
Existing databases created before Alembic integration are automatically detected and stamped with revision `001`:
1. Server detects no `alembic_version` table
2. Checks if `refresh_tokens` table exists
3. If yes, stamps database with `001` (no schema changes)
4. Future updates use normal migration path
### Migration Path
```
Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ...
New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ...
```
## Troubleshooting
### Migration Fails
```bash
# Check current state
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
# View migration history
uv run nextcloud-mcp-server db history -d /path/to/tokens.db
# Manually inspect database
sqlite3 /path/to/tokens.db ".schema"
```
### Reset to Initial State
**WARNING: This destroys all data!**
```bash
# Downgrade to base (empty database)
uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base
# Upgrade to latest
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
### Corrupted Migration State
If `alembic_version` table is corrupted:
```bash
# Manually fix via SQL
sqlite3 /path/to/tokens.db
> DELETE FROM alembic_version;
> INSERT INTO alembic_version (version_num) VALUES ('001');
> .quit
# Verify and upgrade
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
## CI/CD Integration
### Pre-Deployment
```bash
# Run migrations in test environment
export TOKEN_STORAGE_DB=/app/data/tokens.db
uv run nextcloud-mcp-server db upgrade
# Verify current version
uv run nextcloud-mcp-server db current
```
### Docker Deployment
Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`.
### Rollback Plan
1. Stop application
2. Backup database: `cp tokens.db tokens.db.backup`
3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX`
4. Deploy previous application version
5. Restart application
## References
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html)
- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001)
Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

+189 -199
View File
@@ -14,100 +14,10 @@ Before running the server:
## Quick Start
Load your environment variables and start the server:
Start the server using Docker:
```bash
# Load environment variables from .env
export $(grep -v '^#' .env | xargs)
# Start the server
uv run nextcloud-mcp-server
```
The server will start on `http://127.0.0.1:8000` by default.
---
## Running Locally
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
The CLI provides a simple interface with built-in defaults:
#### OAuth Mode
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
uv run nextcloud-mcp-server
# Explicitly force OAuth mode
uv run nextcloud-mcp-server --oauth
# OAuth with custom host and port
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
# OAuth with pre-configured client
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789
# OAuth with specific apps only
uv run nextcloud-mcp-server --oauth \
--enable-app notes \
--enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
uv run nextcloud-mcp-server
# Explicitly force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
# BasicAuth with specific apps
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app webdav
```
### Method 2: Using uvicorn
For more control over server options (workers, reload, etc.):
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run with uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--host 127.0.0.1 \
--port 8000 \
--reload # Enable auto-reload for development
```
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
### Method 3: Using Python Module
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run as Python module
python -m nextcloud_mcp_server.app --oauth --port 8000
```
---
## Running with Docker
### Basic Docker Run
```bash
# OAuth mode
# OAuth mode (recommended)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
@@ -116,11 +26,56 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Docker with Persistent OAuth Storage
The server will start on `http://127.0.0.1:8000` by default.
---
## Running with Docker
### Basic Docker Run
#### OAuth Mode (Recommended)
```bash
# OAuth with auto-registration
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with custom port
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with pre-configured client
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
-e NEXTCLOUD_OIDC_CLIENT_ID=abc123 \
-e NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789 \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with specific apps only
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes --enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# BasicAuth (requires NEXTCLOUD_USERNAME/PASSWORD in .env)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# BasicAuth with specific apps
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
--enable-app notes --enable-app webdav
```
### Docker with Persistent Token Storage
```bash
# Mount volume for persistent OAuth token storage
docker run -p 127.0.0.1:8000:8000 --env-file .env \
-v $(pwd)/.oauth:/app/.oauth \
-v $(pwd)/data:/app/data \
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
@@ -140,7 +95,7 @@ services:
env_file:
- .env
volumes:
- ./oauth-storage:/app/.oauth
- ./data:/app/data # Persistent token storage
restart: unless-stopped
```
@@ -168,30 +123,39 @@ docker-compose down
```bash
# Bind to all interfaces (accessible from network)
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
docker run -p 0.0.0.0:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# Bind to localhost only (default, more secure)
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# Use a different port
uv run nextcloud-mcp-server --port 8080
# Use a different port (map host port 8080 to container port 8000)
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
**Security Note:** Binding to `0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
### Transport Protocols
The server supports multiple MCP transport protocols:
```bash
# Streamable HTTP (recommended)
uv run nextcloud-mcp-server --transport streamable-http
# Streamable HTTP (default, recommended)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport streamable-http
# SSE - Server-Sent Events (default, deprecated)
uv run nextcloud-mcp-server --transport sse
# SSE - Server-Sent Events (deprecated)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport sse
# HTTP
uv run nextcloud-mcp-server --transport http
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport http
```
> [!WARNING]
@@ -201,10 +165,14 @@ uv run nextcloud-mcp-server --transport http
```bash
# Set log level (critical, error, warning, info, debug, trace)
uv run nextcloud-mcp-server --log-level debug
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level debug
# Production: use warning or error
uv run nextcloud-mcp-server --log-level warning
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level warning
```
### Selective App Enablement
@@ -212,22 +180,26 @@ uv run nextcloud-mcp-server --log-level warning
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
```bash
# Available apps: notes, tables, webdav, calendar, contacts, deck
# Available apps: notes, tables, webdav, calendar, contacts, cookbook, deck
# Enable all apps (default)
uv run nextcloud-mcp-server
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# Enable only Notes
uv run nextcloud-mcp-server --enable-app notes
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes
# Enable multiple apps
uv run nextcloud-mcp-server \
--enable-app notes \
--enable-app calendar \
--enable-app contacts
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes --enable-app calendar --enable-app contacts
# Enable only WebDAV for file operations
uv run nextcloud-mcp-server --enable-app webdav
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app webdav
```
**Use cases:**
@@ -240,24 +212,68 @@ uv run nextcloud-mcp-server --enable-app webdav
## Development Mode
For active development with auto-reload:
### Running for Development
For active development with auto-reload, mount your source code as a volume:
```bash
# Using uvicorn with reload
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--reload \
--host 127.0.0.1 \
--port 8000 \
# Development mode with source code mounted
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
-v $(pwd):/app \
-v $(pwd)/data:/app/data \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level debug
```
Or use the CLI with reload flag:
For local development without Docker:
```bash
uv run nextcloud-mcp-server --reload --log-level debug
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run the server with auto-reload
uv run nextcloud-mcp-server run --oauth --log-level debug
```
### CLI Subcommands
The `nextcloud-mcp-server` CLI has two main subcommands:
1. **`run`** - Start the MCP server (default command in Docker)
```bash
uv run nextcloud-mcp-server run --oauth --host 0.0.0.0 --port 8000
```
2. **`db`** - Database migration management (Alembic)
```bash
# Show current migration revision
uv run nextcloud-mcp-server db current
# Upgrade to latest migration
uv run nextcloud-mcp-server db upgrade
# Show migration history
uv run nextcloud-mcp-server db history
# Create new migration (developers only)
uv run nextcloud-mcp-server db migrate "description of changes"
```
### Database Migrations
Token storage uses **Alembic** for schema management:
- **Automatic migrations**: Database is upgraded automatically on server startup
- **Backward compatibility**: Pre-Alembic databases are automatically stamped with the initial revision
- **Migration files**: Located in `alembic/versions/`
- **For developers**: When changing the schema:
1. Create a migration: `uv run nextcloud-mcp-server db migrate "add new column"`
2. Edit the generated file in `alembic/versions/` to add SQL statements
3. Test upgrade: `uv run nextcloud-mcp-server db upgrade`
4. Test downgrade: `uv run nextcloud-mcp-server db downgrade`
See [Database Migrations Guide](database-migrations.md) for detailed information.
---
## Connecting to the Server
@@ -266,15 +282,15 @@ uv run nextcloud-mcp-server --reload --log-level debug
MCP Inspector is a browser-based tool for testing MCP servers:
```bash
# Start MCP Inspector
uv run mcp dev
# In the browser:
# 1. Enter server URL: http://localhost:8000
# 2. Complete OAuth flow (if using OAuth)
# 3. Explore tools and resources
```
1. Start your MCP server using Docker (see above)
2. Start MCP Inspector:
```bash
npx @modelcontextprotocol/inspector
```
3. In the browser:
- Enter server URL: `http://localhost:8000`
- Complete OAuth flow (if using OAuth)
- Explore tools and resources
### Using MCP Clients
@@ -322,48 +338,13 @@ INFO Initializing Nextcloud client with BasicAuth
### Running as a Background Service
#### Using systemd (Linux)
Create `/etc/systemd/system/nextcloud-mcp.service`:
```ini
[Unit]
Description=Nextcloud MCP Server
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/nextcloud-mcp-server
EnvironmentFile=/path/to/.env
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable nextcloud-mcp
sudo systemctl start nextcloud-mcp
sudo systemctl status nextcloud-mcp
```
#### Using Docker Compose
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
Use Docker Compose with `restart: unless-stopped` (see [Docker Compose section](#docker-compose) above).
### Monitoring Logs
```bash
# Local installation with systemd
sudo journalctl -u nextcloud-mcp -f
# Docker
# Docker (find container name first)
docker ps
docker logs -f <container-name>
# Docker Compose
@@ -374,35 +355,38 @@ docker-compose logs -f mcp
## Performance Tuning
### Multiple Workers
For production deployments with higher load:
```bash
# Using CLI (if supported)
uv run nextcloud-mcp-server --workers 4
# Using uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--workers 4 \
--host 0.0.0.0 \
--port 8000
```
### Production Settings
```bash
# Recommended production configuration
uv run nextcloud-mcp-server \
--oauth \
--host 127.0.0.1 \
--port 8000 \
--log-level warning \
--transport streamable-http \
--workers 2
For production deployments, use Docker Compose with the recommended settings:
```yaml
version: '3.8'
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
command: --oauth --log-level warning --transport streamable-http
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
- ./data:/app/data
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
```
### Scaling with Multiple Replicas
For higher load, use Docker Swarm or Kubernetes. See the [Helm Chart](../helm/) for Kubernetes deployments.
---
## Troubleshooting
@@ -411,12 +395,18 @@ uv run nextcloud-mcp-server \
Check logs for errors:
```bash
uv run nextcloud-mcp-server --log-level debug
# View container logs
docker logs <container-name>
# Or run with debug logging
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level debug
```
Common issues:
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
- Port already in use - Try a different port with `--port`
- Environment variables not loaded - Check your `.env` file
- Port already in use - Use a different host port (e.g., `-p 127.0.0.1:8080:8000`)
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
### Can't connect to server
+4 -4
View File
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
> [!IMPORTANT]
> **Status: Experimental**
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
> - RAG answer generation requires MCP client sampling support
@@ -39,9 +39,9 @@ Semantic search enables:
### Current Support
- **Supported Apps**: Notes (fully implemented)
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
- **Architecture**: Multi-app plugin system ready, awaiting implementation
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
- **Architecture**: Multi-app plugin system ready for additional apps
## System Components
+140
View File
@@ -4,6 +4,146 @@ This guide covers common issues and solutions for the Nextcloud MCP server.
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
> **Upgrading from v0.57.x?** See the [Configuration Migration Guide](configuration-migration-v2.md) for help with new variable names.
## Configuration Issues (v0.58.0+)
### Issue: Deprecation warning for VECTOR_SYNC_ENABLED
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
```bash
# In your .env file, replace:
VECTOR_SYNC_ENABLED=true
# With:
ENABLE_SEMANTIC_SEARCH=true
```
See [Configuration Migration Guide](configuration-migration-v2.md) for complete migration instructions.
---
### Issue: Deprecation warning for ENABLE_OFFLINE_ACCESS
**Symptom:**
```
WARNING: ENABLE_OFFLINE_ACCESS is deprecated. Please use ENABLE_BACKGROUND_OPERATIONS instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
**If you have semantic search enabled:**
```bash
# In multi-user modes, you can remove ENABLE_OFFLINE_ACCESS entirely!
# ENABLE_SEMANTIC_SEARCH automatically enables background operations
# Before (v0.57.x):
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
# After (v0.58.0+):
ENABLE_SEMANTIC_SEARCH=true # This is all you need!
```
**If you only want background operations (no semantic search):**
```bash
# Replace:
ENABLE_OFFLINE_ACCESS=true
# With:
ENABLE_BACKGROUND_OPERATIONS=true
```
---
### Issue: "Invalid MCP_DEPLOYMENT_MODE"
**Symptom:**
```
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
```
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
**Solution:**
Use one of the valid mode values:
```bash
# Correct values:
MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/password
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
```
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
---
### Issue: Missing TOKEN_ENCRYPTION_KEY when semantic search enabled
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Cause:** In multi-user modes, semantic search automatically enables background operations, which require encrypted token storage.
**Solution:**
Generate an encryption key and add required token storage configuration:
```bash
# Generate encryption key
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Add to .env:
TOKEN_ENCRYPTION_KEY=<generated-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id # Required for app password retrieval
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
```
**Why this happens:**
- v0.58.0+ automatically enables background operations when `ENABLE_SEMANTIC_SEARCH=true` in multi-user modes
- Background operations need encrypted refresh token storage
- This simplifies configuration but requires the encryption infrastructure
See [Configuration Guide - Semantic Search](configuration.md#semantic-search-configuration-optional) for details.
---
### Issue: Both old and new variable names set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Cause:** You have both the old and new variable names in your configuration.
**Solution:**
Remove the old variable name:
```bash
# Remove this line:
VECTOR_SYNC_ENABLED=true
# Keep this line:
ENABLE_SEMANTIC_SEARCH=true
```
The server will use the new name and ignore the old one, but it's cleaner to remove the old variable entirely.
---
## OAuth Issues (Quick Reference)
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
+93
View File
@@ -0,0 +1,93 @@
# Vector Sync UI Guide
This guide covers the browser-based interface for the Nextcloud MCP Server's semantic search and vector synchronization features.
## Overview
The Vector Sync UI (`/app`) provides an interactive interface to test semantic search queries and visualize results from your Nextcloud documents. It exposes the same retrieval capabilities that LLMs use in Retrieval-Augmented Generation (RAG) workflows, powered by Alpine.js for reactive state, htmx for dynamic updates, and Plotly.js for 3D visualization.
**Supported Apps**: Notes, Files (text/PDF), Calendar (events/tasks), Contacts (CardDAV), and Deck are indexed and searchable.
## Accessing the UI
Navigate to `/app` after authentication:
- **BasicAuth mode**: `http://localhost:8000/app` (uses credentials from environment)
- **OAuth mode**: `http://localhost:8000/app` (redirects to login if not authenticated)
## Tabs
### Welcome Page
Landing page that introduces semantic search and RAG workflows. Shows authentication status, explains how vector embeddings work, and provides feature navigation. Adapts content based on whether `VECTOR_SYNC_ENABLED=true`.
### User Info
Displays authentication details and session information:
- **BasicAuth**: Username, mode badge, Nextcloud host
- **OAuth**: Username, session ID (truncated), background access status, IdP profile, revocation option
### Vector Sync Status
Real-time monitoring of document indexing:
- **Indexed Documents**: Total chunks stored in Qdrant vector database (immediately searchable)
- **Pending Documents**: Queue awaiting embedding processing
- **Status**: "✓ Idle" (green) when up-to-date, "⟳ Syncing" (orange) during processing
Auto-refreshes every 10 seconds via htmx. Check this tab after adding content to verify indexing completion.
### Vector Visualization
Interactive search interface with 3D PCA plot of semantic space.
**Search Controls**:
- **Query**: Natural language search (e.g., "health benefits of coffee")
- **Algorithm**: Semantic (Dense) for pure vector search, or BM25 Hybrid (default) combining vectors + keywords
- **Fusion** (Hybrid only): RRF (Reciprocal Rank Fusion) or DBSF (Distribution-Based Score Fusion)
- **Advanced**: Filter by document type, adjust score threshold (0.0-1.0), set result limit (max 100)
**3D Visualization**:
The plot uses Principal Component Analysis (PCA) to reduce 768-dimensional embeddings to 3D. Documents are positioned by semantic similarity with the query point shown in red. Point size and opacity indicate relevance, and the Viridis color scale shows relative scores (yellow = highest match).
**Critical Fix**: Vectors are L2-normalized before PCA to match Qdrant's cosine distance, ensuring query points position accurately near similar documents. Without normalization, magnitude differences cause misleading spatial separation.
**Results List**:
Each result shows document title (clickable link to Nextcloud), excerpt, raw score, relative percentage, and document type. Click "Show Chunk" to view the matched text segment with surrounding context (up to 500 characters before/after).
## Configuration
**Required**:
```bash
VECTOR_SYNC_ENABLED=true
```
**Optional** (for browser-accessible links):
```bash
NEXTCLOUD_PUBLIC_ISSUER_URL=https://your-public-nextcloud-url.com
```
**Admin Access**: Webhooks tab only visible to Nextcloud admins (verified via Provisioning API).
## Use Cases
**Testing Search Queries**: Preview results before they reach LLMs in RAG workflows. Compare semantic vs. hybrid algorithms, verify relevance scores, and validate that correct documents are retrieved. Use chunk context to see exactly which text segments match and why unexpected documents appear.
**Monitoring Indexing**: Track real-time progress after creating or modifying documents. Check if the queue is backing up (high pending count) or confirm the system is idle after bulk imports. Verify documents become searchable immediately after indexing completes.
**Algorithm Comparison**: Pure semantic search excels at conceptual queries and synonyms. BM25 hybrid combines semantic understanding with precise keyword matching for better accuracy on specific terms. Experiment with RRF vs. DBSF fusion for different score distributions.
## Troubleshooting
**Vector Sync Tab Not Visible**: Set `VECTOR_SYNC_ENABLED=true` and restart the server.
**No Search Results**: Check Vector Sync Status to confirm documents are indexed (not just pending). Try broader queries or lower the score threshold in Advanced options. Initial indexing may take time depending on document volume.
**Links to Nextcloud Apps Not Working**: Set `NEXTCLOUD_PUBLIC_ISSUER_URL` to your browser-accessible Nextcloud URL for correct link generation.
## Related Documentation
- [Configuration Guide](../configuration.md) - Environment variables and settings
- [Authentication Modes](../authentication.md) - BasicAuth vs OAuth setup
- [Installation Guide](../installation.md) - Getting started
- [ADR-008: MCP Sampling for RAG](../ADR-008-mcp-sampling-for-rag.md) - Technical details on RAG workflows
+339
View File
@@ -0,0 +1,339 @@
# Webhook Management Guide
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
**Related ADRs:**
- ADR-010: Webhook-Based Vector Sync
- ADR-020: Deployment Modes and Configuration Validation
## Prerequisites
Before enabling webhooks, ensure:
1. **Nextcloud 30+** with `webhook_listeners` app enabled
2. **[Astrolabe app](https://github.com/cbcoutinho/astrolabe)** installed in Nextcloud (provides settings UI and credentials API)
3. **MCP server** accessible from Nextcloud via HTTP(S)
4. **Vector sync enabled** on the MCP server
## Webhook Architecture Overview
The webhook system has two components:
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
Both must be configured for webhooks to function properly.
## Deployment Mode Specifics
### 1. Single-User BasicAuth
**Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
1. Register webhooks using occ commands (requires Nextcloud admin):
```bash
# Enable webhook_listeners app
php occ app:enable webhook_listeners
# Register webhooks for vector sync
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8000/webhooks/nextcloud" \
--method POST
# Repeat for other events (see Event Types below)
```
2. Optionally reduce polling frequency:
```bash
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
```
**Disable Webhooks:**
```bash
# List registered webhooks
php occ webhook_listeners:list
# Remove specific webhook by ID
php occ webhook_listeners:remove <webhook-id>
```
**Notes:**
- Simplest mode - admin credentials used for all operations
- No per-user provisioning required
- Background sync runs as the configured admin user
---
### 2. Multi-User BasicAuth Pass-Through
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
# OAuth client for Astrolabe API access
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
**Credential Architecture:**
This mode uses **two separate credential mechanisms**:
1. **OAuth Session** (for management API access, including webhooks):
- Obtained via browser OAuth flow (`/oauth/login`)
- Stores refresh token in MCP server's `tokens.db`
- Used for webhook registration/management APIs
2. **App Password** (for background sync):
- Generated in Nextcloud Security settings
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
- Used by background scanners to access Nextcloud APIs
**Enable Webhooks:**
#### Step 1: Complete OAuth Login (for Management API)
Users must authorize the MCP server to access their Nextcloud:
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
2. Click **"Authorize via OAuth"** under "Option 1"
3. Complete OAuth consent flow
4. Verify the page shows "Background Sync Access: Active"
#### Step 2: Configure App Password (for Background Sync)
Since OAuth refresh tokens have short expiry, users should also configure an app password:
1. Navigate to **Nextcloud Settings → Security**
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
3. Return to **Nextcloud Settings → Astrolabe**
4. Under "Option 2: App Password", paste the app password
5. Click **Save**
#### Step 3: Register Webhooks (Admin)
Same as Single-User BasicAuth:
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8003/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
**Troubleshooting:**
If OAuth login fails with "Access forbidden - Your client is not authorized":
1. Check if OAuth client is registered:
```sql
SELECT id, name, client_identifier FROM oc_oidc_clients
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
```
2. Restart MCP server to trigger DCR re-registration
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
If background sync fails with "User no longer provisioned":
1. Verify app password is stored:
```sql
SELECT userid, configkey FROM oc_preferences
WHERE appid = 'astrolabe' AND userid = 'username';
```
2. Ensure user completed **both** OAuth login AND app password setup
---
### 3. OAuth Single-Audience (Default OAuth Mode)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
#### Step 1: User Provisioning
Users authorize via OAuth with `offline_access` scope:
1. MCP client initiates OAuth flow
2. User consents to requested scopes including `offline_access`
3. MCP server stores refresh token for background operations
Alternatively, via Astrolabe UI:
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Authorize via OAuth"**
3. Complete consent flow
#### Step 2: Register Webhooks (Admin)
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8001/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
- Via MCP tool: Use `revoke_nextcloud_access` if available
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
---
### 4. OAuth Token Exchange (RFC 8693)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable/Disable Webhooks:**
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
---
### 5. Smithery Stateless
**Configuration:**
- Configuration from session URL params
- `VECTOR_SYNC_ENABLED=false` (required)
**Webhooks:**
**Not supported.** This mode is stateless with no persistent storage or background operations.
---
## Webhook Event Types
Register these webhook events for full vector sync coverage:
### File/Note Events
```bash
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Calendar Events
```bash
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Tables Events
```bash
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
## Security Considerations
### Webhook Authentication
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
```bash
# MCP Server
WEBHOOK_SECRET=<generate-random-secret>
# Nextcloud webhook registration
php occ webhook_listeners:add \
--event "..." \
--uri "$MCP_URL/webhooks/nextcloud" \
--header "Authorization: Bearer <secret>"
```
### Token Storage
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
- Store the key securely (environment variable, secrets manager)
- Different users have isolated credential storage
## Monitoring
### MCP Server Logs
```bash
# Docker
docker compose logs mcp-multi-user-basic | grep -i webhook
# Key log messages
# - "Queued document from webhook: ..." - Success
# - "Webhook authentication failed" - Auth error
# - "User X no longer provisioned" - Missing credentials
```
### Nextcloud Logs
```bash
docker compose exec app cat /var/www/html/data/nextcloud.log | \
jq 'select(.message | contains("webhook"))' | tail
```
### Database Checks
```sql
-- Check registered webhooks
SELECT * FROM oc_webhook_listeners;
-- Check OAuth clients
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
-- Check user credentials stored by Astrolabe app
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
```
## Common Issues
### "Access forbidden - Your client is not authorized to connect"
**Cause:** OAuth client registration expired or not present in Nextcloud
**Fix:** Restart MCP server to trigger DCR re-registration
### "User X no longer provisioned, stopping scanner"
**Cause:** Background sync credentials missing or expired
**Fix:** User must complete credential provisioning (see mode-specific steps)
### "Failed to fetch" in browser console during OAuth
**Cause:** Network issue between browser and MCP server callback endpoint
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
### Webhooks not firing
**Causes:**
1. `webhook_listeners` app not enabled
2. Webhook not registered for the event type
3. Background job workers not running
**Fix:**
```bash
php occ app:enable webhook_listeners
php occ background:cron # or configure systemd cron
```
+216 -165
View File
@@ -1,198 +1,249 @@
# Nextcloud Instance
# ============================================
# DEPLOYMENT MODE SELECTION
# ============================================
# Optional: Explicitly declare deployment mode (ADR-021)
# If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
#
# Recommendation: Set this for clarity and to catch configuration errors early
#MCP_DEPLOYMENT_MODE=oauth_single_audience
# ============================================
# COMMON SETTINGS (Required for all modes)
# ============================================
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=
# ===== AUTHENTICATION MODE =====
# Choose ONE of the following:
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#TOKEN_ENCRYPTION_KEY=
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
#TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
# Enable Progressive Consent mode (dual OAuth flows)
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
# When disabled: Uses existing hybrid flow (backward compatible)
# MCP Server OAuth Client Configuration
# The MCP server's own OAuth client credentials for Flow 2
# If not set, will use dynamic client registration
#MCP_SERVER_CLIENT_ID=
#MCP_SERVER_CLIENT_SECRET=
# Allowed MCP Client IDs (comma-separated list)
# Client IDs that are allowed to authenticate in Flow 1
# Examples: claude-desktop,continue-dev,zed-editor
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
# Token cache configuration for Token Broker Service
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_CACHE_TTL=300
# Early refresh threshold in seconds (default: 30)
#TOKEN_CACHE_EARLY_REFRESH=30
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
# - Use only for backward compatibility or if OAuth unavailable
# - If these are set, OAuth mode is disabled
# ============================================
# SINGLE-USER BASICAUTH MODE
# ============================================
# Simplest deployment - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Required:
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# Document Processing Configuration
# MULTI-USER BASICAUTH MODE
# ============================================
# Enable document processing (PDF, DOCX, images, etc.)
# Set to false to disable all document processing
ENABLE_DOCUMENT_PROCESSING=false
# Default processor to use when multiple are available
# Options: unstructured, tesseract, custom
DOCUMENT_PROCESSOR=unstructured
# Users provide credentials in request headers (pass-through)
# Use for: Multi-user without OAuth, simple shared deployments
#
# Required:
#ENABLE_MULTI_USER_BASIC_AUTH=true
#
# Optional - Background Operations (for semantic search, future features):
# Enable background token storage using app passwords (via Astrolabe)
# Required for semantic search in multi-user mode
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# Unstructured.io Processor
# OAUTH SINGLE-AUDIENCE MODE (Recommended)
# ============================================
# Enable Unstructured processor (requires unstructured service in docker-compose)
# This is a cloud-based/API processor supporting many document types
ENABLE_UNSTRUCTURED=false
# Unstructured API endpoint
UNSTRUCTURED_API_URL=http://unstructured:8000
# Request timeout in seconds (default: 120)
# OCR operations can take 30-120 seconds for large documents
UNSTRUCTURED_TIMEOUT=120
# Parsing strategy: auto, fast, hi_res
# - auto: Automatically choose based on document type
# - fast: Fast parsing without OCR
# - hi_res: High-resolution with OCR (slowest, most accurate)
UNSTRUCTURED_STRATEGY=auto
# OCR languages (comma-separated ISO 639-3 codes)
# Common: eng=English, deu=German, fra=French, spa=Spanish
UNSTRUCTURED_LANGUAGES=eng,deu
# Progress reporting interval in seconds (default: 10)
# During long-running OCR operations, progress notifications are sent to the MCP client
# at this interval to prevent timeouts and provide status updates
PROGRESS_INTERVAL=10
# Multi-user OAuth with single-audience tokens
# Use for: Multi-user production deployments, enhanced security
# Tokens work for both MCP server and Nextcloud APIs (pass-through)
#
# Required: None (uses Dynamic Client Registration if credentials not provided)
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Background Operations (for semantic search, future features):
# Enable refresh token storage for offline access
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
# Auto-detected from NEXTCLOUD_HOST if not set
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# Optional - Custom Scopes:
# Default: openid profile email offline_access notes:* calendar:* contacts:* tables:* webdav:* deck:* cookbook:*
#NEXTCLOUD_OIDC_SCOPES=openid profile email notes:* calendar:*
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# Tesseract Processor (Local OCR)
# OAUTH TOKEN EXCHANGE MODE (Advanced)
# ============================================
# Enable Tesseract processor (requires tesseract binary installed)
# This is a local, lightweight OCR solution for images only
ENABLE_TESSERACT=false
# Path to tesseract executable (optional, auto-detected if in PATH)
#TESSERACT_CMD=/usr/bin/tesseract
# OCR language (e.g., eng, deu, eng+deu for multiple)
TESSERACT_LANG=eng
# Multi-user OAuth with RFC 8693 token exchange
# Use for: Advanced deployments requiring separate MCP and Nextcloud tokens
# MCP tokens are separate from Nextcloud tokens
#
# Required:
#ENABLE_TOKEN_EXCHANGE=true
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Token Exchange Configuration:
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_EXCHANGE_CACHE_TTL=300
#
# Optional - Background Operations:
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# Custom Processor (Your own API)
# SMITHERY STATELESS MODE
# ============================================
# Enable custom document processor via HTTP API
ENABLE_CUSTOM_PROCESSOR=false
# Unique name for your processor
#CUSTOM_PROCESSOR_NAME=my_ocr
# Your custom processor API endpoint
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
# Optional API key for authentication
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
# Request timeout in seconds
#CUSTOM_PROCESSOR_TIMEOUT=60
# Comma-separated MIME types your processor supports
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# Stateless multi-tenant deployment for Smithery platform
# Configuration comes from session URL parameters
# No persistent storage, no OAuth, no vector sync
#
# Required: None (all config from session URL)
# This mode is activated automatically when deployed to Smithery
# ============================================
# Semantic Search & Vector Sync Configuration
# OPTIONAL FEATURES (All Deployment Modes)
# ============================================
# EXPERIMENTAL: Semantic search for Notes app (multi-app support planned)
# Requires: Qdrant vector database + Ollama embedding service
# Disabled by default
# Enable background vector indexing
VECTOR_SYNC_ENABLED=false
# ===== SEMANTIC SEARCH =====
# AI-powered semantic search across Nextcloud content
# Requires: Qdrant vector database + embedding provider (Ollama, Bedrock, or Simple fallback)
#
# Enable semantic search:
#ENABLE_SEMANTIC_SEARCH=true
#
# Note for Multi-User Modes:
# ENABLE_SEMANTIC_SEARCH automatically enables background operations when needed
# No need to set ENABLE_BACKGROUND_OPERATIONS separately
# The server will automatically request refresh tokens and store them encrypted
#
# Vector Database - Choose ONE mode:
# 1. In-memory (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network: Set QDRANT_URL=http://qdrant:6333
#
#QDRANT_URL=http://qdrant:6333
#QDRANT_LOCATION=:memory:
#QDRANT_API_KEY=
#QDRANT_COLLECTION=nextcloud_content
#
# Embedding Provider - Choose ONE:
# 1. Ollama (recommended for local deployment):
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
#OLLAMA_VERIFY_SSL=true
#
# 2. Amazon Bedrock (for AWS deployments):
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Optional: AWS credentials (uses credential chain if not set)
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=
#
# 3. Simple (automatic fallback, no configuration needed)
# Uses basic in-memory embeddings if no provider configured
#
# Document Chunking:
# Configure how documents are split before embedding
#DOCUMENT_CHUNK_SIZE=512
#DOCUMENT_CHUNK_OVERLAP=50
# ===== SEMANTIC SEARCH TUNING =====
# Advanced parameters for vector sync background operations
# Only modify if you understand the implications
#
# Document scan interval in seconds (default: 300 = 5 minutes)
# How often to check for new/updated documents
#VECTOR_SYNC_SCAN_INTERVAL=300
#
# Concurrent indexing workers (default: 3)
# Number of parallel workers for embedding generation
#VECTOR_SYNC_PROCESSOR_WORKERS=3
#
# Max queued documents (default: 10000)
# Maximum documents waiting to be processed
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
# ============================================
# Qdrant Vector Database Configuration
# ============================================
# Choose ONE of three modes:
# 1. In-memory mode (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network mode: Set QDRANT_URL=http://qdrant:6333
# ===== DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX, etc. for semantic search
# Disabled by default
#
#ENABLE_DOCUMENT_PROCESSING=false
#DOCUMENT_PROCESSOR=unstructured
#
# Unstructured.io Processor (recommended):
#ENABLE_UNSTRUCTURED=false
#UNSTRUCTURED_API_URL=http://unstructured:8000
#UNSTRUCTURED_TIMEOUT=120
#UNSTRUCTURED_STRATEGY=auto
#UNSTRUCTURED_LANGUAGES=eng,deu
#PROGRESS_INTERVAL=10
#
# Tesseract OCR (lightweight, images only):
#ENABLE_TESSERACT=false
#TESSERACT_CMD=/usr/bin/tesseract
#TESSERACT_LANG=eng
#
# Custom Processor (your own API):
#ENABLE_CUSTOM_PROCESSOR=false
#CUSTOM_PROCESSOR_NAME=my_ocr
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
#CUSTOM_PROCESSOR_API_KEY=
#CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# Network mode: URL to Qdrant service
#QDRANT_URL=http://qdrant:6333
# ===== SSL/TLS =====
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
#
# Disable TLS certificate verification (insecure, development only):
#NEXTCLOUD_VERIFY_SSL=false
#
# Use a custom CA bundle (path to PEM file):
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
#
# Docker example: mount the CA bundle as a volume
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
# Local mode: Path to store vectors (use :memory: for in-memory)
#QDRANT_LOCATION=:memory:
# API key for network mode (optional)
#QDRANT_API_KEY=
# Collection name (optional - auto-generated if not set)
# Auto-generation format: {deployment-id}-{model-name}
# Allows safe model switching and multi-server deployments
#QDRANT_COLLECTION=nextcloud_content
# ===== SECURITY & ADVANCED =====
# Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set
#COOKIE_SECURE=true
# ============================================
# Ollama Embedding Service Configuration
# DEPRECATED VARIABLES (Backward Compatibility)
# ============================================
# Ollama endpoint for embeddings (if not set, uses SimpleEmbeddingProvider fallback)
#OLLAMA_BASE_URL=http://ollama:11434
# Embedding model to use (default: nomic-embed-text, 768 dimensions)
# Changing this creates a new collection (requires re-embedding all documents)
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Verify SSL certificates (default: true)
#OLLAMA_VERIFY_SSL=true
# ============================================
# Document Chunking Configuration
# ============================================
# Configure how documents are split before embedding
# Words per chunk (default: 512)
# Smaller chunks (256-384): More precise, less context, more storage
# Larger chunks (768-1024): More context, less precise, less storage
#DOCUMENT_CHUNK_SIZE=512
# Overlapping words between chunks (default: 50)
# Recommended: 10-20% of chunk size
# Preserves context across chunk boundaries
#DOCUMENT_CHUNK_OVERLAP=50
# These variables still work but will be removed in v1.0.0
# Please migrate to new names:
#
# Old Name → New Name
# VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
# ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS
#
# Migration is optional - both old and new names work
# Deprecation warnings will be logged when old names are used
+80
View File
@@ -0,0 +1,80 @@
# ============================================
# OAUTH TOKEN EXCHANGE QUICK START (Advanced)
# ============================================
# Advanced OAuth deployment with RFC 8693 token exchange
# Use for: Deployments requiring separate MCP and Nextcloud tokens
# Features: Dual-audience tokens, enhanced security boundaries
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# Enable token exchange mode
ENABLE_TOKEN_EXCHANGE=true
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: TOKEN EXCHANGE TUNING =====
# Cache TTL for exchanged tokens (default: 300 seconds = 5 minutes)
TOKEN_EXCHANGE_CACHE_TTL=300
# ===== OPTIONAL: SEMANTIC SEARCH =====
# AI-powered semantic search with automatic background operation setup
#
# Note: ENABLE_SEMANTIC_SEARCH automatically enables background operations
# in token exchange mode, just like in OAuth single-audience mode
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# Embedding Provider (required for semantic search)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== TOKEN EXCHANGE MODE EXPLANATION =====
# In this mode:
# 1. MCP clients authenticate with tokens scoped to "mcp-server" audience
# 2. Server exchanges MCP tokens for Nextcloud tokens on each request
# 3. Provides clear separation between MCP session and Nextcloud access
# 4. Enables fine-grained token lifecycle management
#
# When to use:
# - Strict security requirements (separate token contexts)
# - Complex multi-service architectures
# - Need independent token expiration policies
#
# When NOT to use:
# - Simple deployments (use oauth_single_audience instead)
# - High-performance requirements (token exchange adds latency)
# For more configuration options, see env.sample
+77
View File
@@ -0,0 +1,77 @@
# ============================================
# OAUTH MULTI-USER QUICK START (Recommended)
# ============================================
# Multi-user deployment with OAuth authentication
# Use for: Multi-user production deployments, enhanced security
# Features: Single-audience tokens, automatic client registration (DCR)
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_single_audience
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: SEMANTIC SEARCH (Recommended) =====
# AI-powered semantic search with automatic background operation setup
#
# When you enable semantic search in multi-user mode:
# 1. ENABLE_SEMANTIC_SEARCH automatically enables background operations
# 2. Server requests refresh tokens for offline indexing
# 3. Tokens are stored encrypted in TOKEN_STORAGE_DB
# 4. No need to set ENABLE_BACKGROUND_OPERATIONS separately!
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# OR for in-memory mode:
#QDRANT_LOCATION=:memory:
# Embedding Provider (required for semantic search)
# Option 1: Ollama (recommended for local deployment)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Option 2: Amazon Bedrock (for AWS deployments)
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== SUMMARY OF AUTO-ENABLEMENT =====
# With ENABLE_SEMANTIC_SEARCH=true in OAuth mode:
# ✅ Background operations enabled automatically
# ✅ Refresh token storage enabled automatically
# ✅ OAuth credentials required (DCR or pre-registered)
# ✅ Encryption key required for token storage
#
# You only need to set ENABLE_SEMANTIC_SEARCH and provide the required
# infrastructure (Qdrant, Ollama, encryption key). The rest is automatic!
# For more advanced configuration, see env.sample
+37
View File
@@ -0,0 +1,37 @@
# ============================================
# SINGLE-USER BASICAUTH QUICK START
# ============================================
# Simplest deployment mode - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Copy this file to .env and fill in your credentials
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=http://localhost:8080
# Your Nextcloud credentials
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended to avoid ambiguity
MCP_DEPLOYMENT_MODE=single_user_basic
# ===== OPTIONAL: SEMANTIC SEARCH =====
# Uncomment to enable AI-powered semantic search
# Requires: Qdrant + embedding provider (Ollama or Bedrock)
#
#ENABLE_SEMANTIC_SEARCH=true
#QDRANT_LOCATION=:memory:
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# That's it! Single-user mode is the simplest to configure.
# For more options, see env.sample
+133
View File
@@ -0,0 +1,133 @@
"""Alembic environment configuration for nextcloud-mcp-server.
This module configures how Alembic runs database migrations for the
token storage database. It supports both online and offline migration modes.
Uses anyio for async operations, consistent with the project's async patterns.
"""
import logging
from pathlib import Path
import anyio
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Configure logging
logger = logging.getLogger("alembic.env")
# This is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Update script location to point to package location
# This allows alembic to find migrations when installed in site-packages
script_location = Path(__file__).parent
config.set_main_option("script_location", str(script_location))
# We don't use SQLAlchemy models, so target_metadata is None
# Migrations will be written manually using op.execute() for raw SQL
target_metadata = None
def get_database_url() -> str:
"""
Get the database URL from Alembic config or environment.
The URL can be set in alembic.ini or passed via -x database_url=...
when running Alembic commands.
Returns:
Database URL (SQLite URL format)
"""
# Check if URL is passed via -x database_url=...
url = context.get_x_argument(as_dictionary=True).get("database_url")
if not url:
# Fall back to alembic.ini configuration
url = config.get_main_option("sqlalchemy.url")
if not url:
# Default to /app/data/tokens.db for Docker deployments
db_path = Path("/app/data/tokens.db")
url = f"sqlite+aiosqlite:///{db_path}"
logger.warning(
f"No database URL configured, using default: {url}. "
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..."
)
return url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL and not an Engine,
though an Engine is acceptable here as well. By skipping the
Engine creation we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
This mode is useful for generating SQL scripts without database access.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Execute migrations within a database connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async support.
In this scenario we create an async Engine and associate
a connection with the context.
"""
# Get database URL and update config
url = get_database_url()
config.set_main_option("sqlalchemy.url", url)
# Create async engine
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # Don't pool connections for migrations
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
This function is called from storage.py's initialize() method via
anyio.to_thread.run_sync(), so it always runs in a worker thread
with its own event loop. We can safely use anyio.run() here.
"""
anyio.run(run_async_migrations)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -0,0 +1,185 @@
"""Initial schema for token storage database
This migration creates the initial database schema including:
- refresh_tokens: OAuth refresh tokens and user profiles
- audit_logs: Audit trail for security events
- oauth_clients: OAuth client credentials (DCR)
- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent)
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
Revision ID: 001
Revises:
Create Date: 2025-12-17 22:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create initial database schema."""
# Refresh tokens table (OAuth mode only, for background jobs)
op.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
token_audience TEXT DEFAULT 'nextcloud',
provisioned_at INTEGER,
provisioning_client_id TEXT,
scopes TEXT,
-- Browser session profile cache
user_profile TEXT,
profile_cached_at INTEGER
)
"""
)
# Audit logs table (both OAuth and BasicAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
)
"""
)
# Index on audit logs for efficient queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
ON audit_logs(user_id, timestamp)
"""
)
# OAuth client credentials storage (OAuth mode only)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# OAuth flow sessions (ADR-004 Progressive Consent)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_sessions (
session_id TEXT PRIMARY KEY,
client_id TEXT,
client_redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
mcp_authorization_code TEXT UNIQUE,
idp_access_token TEXT,
idp_refresh_token TEXT,
user_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
requested_scopes TEXT,
granted_scopes TEXT,
is_provisioning BOOLEAN DEFAULT FALSE
)
"""
)
# Index for MCP authorization code lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code
ON oauth_sessions(mcp_authorization_code)
"""
)
# Legacy schema version tracking table
# NOTE: This is deprecated in favor of Alembic's alembic_version table
# Kept for backward compatibility with pre-Alembic databases
op.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
"""
)
# Registered webhooks tracking (both BasicAuth and OAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS registered_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL UNIQUE,
preset_id TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
# Indexes for efficient webhook queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_preset
ON registered_webhooks(preset_id)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_created
ON registered_webhooks(created_at)
"""
)
def downgrade() -> None:
"""Drop all tables and indexes.
WARNING: This will destroy all data in the database!
Use with extreme caution.
"""
# Drop indexes first
op.execute("DROP INDEX IF EXISTS idx_webhooks_created")
op.execute("DROP INDEX IF EXISTS idx_webhooks_preset")
op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code")
op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp")
# Drop tables
op.execute("DROP TABLE IF EXISTS registered_webhooks")
op.execute("DROP TABLE IF EXISTS schema_version")
op.execute("DROP TABLE IF EXISTS oauth_sessions")
op.execute("DROP TABLE IF EXISTS oauth_clients")
op.execute("DROP TABLE IF EXISTS audit_logs")
op.execute("DROP TABLE IF EXISTS refresh_tokens")
@@ -0,0 +1,50 @@
"""Add app_passwords table for multi-user BasicAuth mode
This migration adds support for storing app passwords that are provisioned
via Astrolabe's personal settings. This enables background sync in
multi-user BasicAuth mode without requiring OAuth.
Revision ID: 002
Revises: 001
Create Date: 2026-01-13 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "002"
down_revision = "001"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add app_passwords table for multi-user BasicAuth mode."""
# App passwords table for multi-user BasicAuth background sync
op.execute(
"""
CREATE TABLE IF NOT EXISTS app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# Index for efficient user lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
def downgrade() -> None:
"""Drop app_passwords table."""
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
op.execute("DROP TABLE IF EXISTS app_passwords")
@@ -0,0 +1,95 @@
"""Add scopes and login flow sessions for Login Flow v2
This migration adds support for:
1. Scoped app passwords (scopes column + username column on app_passwords)
2. Login Flow v2 session tracking (login_flow_sessions table)
Nullable scopes preserves backward compat: NULL = legacy app password = all scopes allowed.
Revision ID: 003
Revises: 002
Create Date: 2026-02-27 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add scopes/username to app_passwords and create login_flow_sessions."""
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
"""
)
# Add username column (Nextcloud loginName from Login Flow v2)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN username TEXT
"""
)
# Login Flow v2 session tracking
op.execute(
"""
CREATE TABLE IF NOT EXISTS login_flow_sessions (
user_id TEXT PRIMARY KEY,
encrypted_poll_token BLOB NOT NULL,
poll_endpoint TEXT NOT NULL,
requested_scopes TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
# Index for efficient cleanup of expired sessions
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
ON login_flow_sessions(expires_at)
"""
)
def downgrade() -> None:
"""Drop login_flow_sessions and remove added columns."""
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
# SQLite doesn't support DROP COLUMN before 3.35.0
# Recreate app_passwords without the new columns
op.execute(
"""
CREATE TABLE app_passwords_backup (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
"""
)
op.execute("DROP TABLE app_passwords")
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
+86
View File
@@ -0,0 +1,86 @@
"""Management API for Nextcloud MCP Server.
Provides REST endpoints for the Nextcloud PHP app to query server status,
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
authentication via the UnifiedTokenVerifier.
This package is organized into modules by domain:
- management.py: Server status, user sessions, shared helpers
- passwords.py: App password provisioning for multi-user BasicAuth
- webhooks.py: Webhook registration management
- visualization.py: Search and PDF visualization endpoints
"""
from nextcloud_mcp_server.api.access import (
get_user_access,
list_supported_scopes,
update_user_scopes,
)
# Re-export all public functions for backward compatibility
from nextcloud_mcp_server.api.management import (
__version__,
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
get_server_status,
get_user_session,
get_vector_sync_status,
revoke_user_access,
validate_token_and_get_user,
)
from nextcloud_mcp_server.api.passwords import (
delete_app_password,
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.api.visualization import (
get_chunk_context,
get_pdf_preview,
unified_search,
vector_search,
)
from nextcloud_mcp_server.api.webhooks import (
create_webhook,
delete_webhook,
get_installed_apps,
list_webhooks,
)
__all__ = [
# Access endpoints (from access.py)
"get_user_access",
"update_user_scopes",
"list_supported_scopes",
# Version
"__version__",
# Shared helpers (from management.py)
"extract_bearer_token",
"validate_token_and_get_user",
"_sanitize_error_for_client",
"_parse_int_param",
"_parse_float_param",
"_validate_query_string",
# Status endpoints (from management.py)
"get_server_status",
"get_vector_sync_status",
# Session endpoints (from management.py)
"get_user_session",
"revoke_user_access",
# Password endpoints (from passwords.py)
"provision_app_password",
"get_app_password_status",
"delete_app_password",
# Webhook endpoints (from webhooks.py)
"get_installed_apps",
"list_webhooks",
"create_webhook",
"delete_webhook",
# Visualization endpoints (from visualization.py)
"unified_search",
"vector_search",
"get_chunk_context",
"get_pdf_preview",
]
+173
View File
@@ -0,0 +1,173 @@
"""Access and scope management API endpoints.
Provides REST API endpoints for querying and managing user access status
and application-level scopes for Login Flow v2 mode.
"""
import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.api.passwords import (
_extract_basic_auth,
_get_app_password_storage,
)
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
logger = logging.getLogger(__name__)
async def get_user_access(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes.
Returns the user's current provisioning status, granted scopes, and metadata.
Requires BasicAuth with the user's credentials.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
data = await storage.get_app_password_with_scopes(username)
if data is None:
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": False,
"scopes": None,
"username": None,
}
)
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": True,
"scopes": data["scopes"],
"username": data.get("username"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_user_access")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def update_user_scopes(request: Request) -> JSONResponse:
"""PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes.
Accepts JSON body with:
- scopes: list[str] - New scope set to apply
This only updates the stored scopes, not the app password itself.
The app password remains valid; scope enforcement is application-level.
Security note: This endpoint allows direct scope modification without
re-authenticating via Login Flow. The caller must authenticate with
valid BasicAuth credentials (user_id + app_password), which serves
as the authorization check.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
body = await request.json()
except Exception:
return JSONResponse(
{"success": False, "error": "Invalid JSON body"},
status_code=400,
)
scopes = body.get("scopes")
if scopes is None or not isinstance(scopes, list):
return JSONResponse(
{"success": False, "error": "scopes must be a list of strings"},
status_code=400,
)
# Validate scopes
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
return JSONResponse(
{
"success": False,
"error": f"Invalid scopes: {', '.join(invalid)}",
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
},
status_code=400,
)
try:
storage = await _get_app_password_storage(request)
existing = await storage.get_app_password_with_scopes(username)
if existing is None:
return JSONResponse(
{
"success": False,
"error": "No app password provisioned for this user",
},
status_code=404,
)
# Update scopes only (no decrypt/re-encrypt of the password)
await storage.update_app_password_scopes(
user_id=username,
scopes=scopes,
)
# Invalidate scope cache so subsequent tool calls see updated scopes
invalidate_scope_cache(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"scopes": scopes,
"message": "Scopes updated successfully",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "update_user_scopes")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def list_supported_scopes(_: Request) -> JSONResponse:
"""GET /api/v1/scopes - List all supported application-level scopes."""
return JSONResponse(
{
"success": True,
"scopes": sorted(ALL_SUPPORTED_SCOPES),
}
)
+514
View File
@@ -0,0 +1,514 @@
"""Management API endpoints for Nextcloud PHP app integration.
ADR-018: Provides REST API endpoints for the Nextcloud PHP app to query:
- Server status and version
- User session information and background access status
- Vector sync metrics
All endpoints use OAuth bearer token authentication via UnifiedTokenVerifier.
The PHP app obtains tokens through PKCE flow and uses them to access these endpoints.
Shared helper functions for other API modules are also exported from here:
- extract_bearer_token: Extract OAuth token from request
- validate_token_and_get_user: Validate token and get user ID
- _sanitize_error_for_client: Return safe error messages
- _parse_int_param, _parse_float_param, _validate_query_string: Parameter validation
"""
import logging
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__)
# Get package version from metadata
__version__ = version("nextcloud-mcp-server")
# Track server start time for uptime calculation
_server_start_time = time.time()
def extract_bearer_token(request: Request) -> str | None:
"""Extract OAuth bearer token from Authorization header.
Args:
request: Starlette request
Returns:
Token string or None if no valid Authorization header
"""
auth_header = request.headers.get("Authorization")
if not auth_header:
return None
# Parse "Bearer <token>"
parts = auth_header.split()
if len(parts) != 2 or parts[0].lower() != "bearer":
return None
return parts[1]
async def validate_token_and_get_user(
request: Request,
) -> tuple[str, dict[str, Any]]:
"""Validate OAuth bearer token and extract user ID.
Uses verify_token_for_management_api which accepts any valid Nextcloud OIDC
token (not just MCP-audience tokens). This is needed because Astrolabe
(NC PHP app) uses its own OAuth client, separate from MCP server's client.
Security Model:
~~~~~~~~~~~~~~~
- **Authentication** (this function): Verifies token is cryptographically valid
and extracts user identity from the `sub` claim.
- **Authorization** (calling endpoints): Each endpoint MUST verify that the
authenticated user owns the requested resource. For example:
- GET /users/{user_id}/session: Checks token_user_id == path_user_id (403 if mismatch)
- POST /users/{user_id}/revoke: Checks token_user_id == path_user_id (403 if mismatch)
This separation ensures that even without audience validation, users can only
access their own resources. Cross-user access is blocked at the authorization layer.
Args:
request: Starlette request with Authorization header
Returns:
Tuple of (user_id, validated_token_data)
Raises:
Exception: If token is invalid or missing
"""
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get token verifier from app state
# Note: This is set in app.py starlette_lifespan for OAuth mode
token_verifier = request.app.state.oauth_context["token_verifier"]
# Validate token for management API (handles both JWT and opaque tokens)
# Uses verify_token_for_management_api which accepts any valid Nextcloud token
# without requiring MCP audience - needed for Astrolabe integration (ADR-018)
access_token = await token_verifier.verify_token_for_management_api(token)
if not access_token:
raise ValueError("Token validation failed")
# Extract user ID from AccessToken.resource field (set during verification)
user_id = access_token.resource
if not user_id:
raise ValueError("Token missing user identifier")
# Return user_id and a dict with token info for compatibility
validated = {
"sub": user_id,
"client_id": access_token.client_id,
"scopes": access_token.scopes,
"expires_at": access_token.expires_at,
}
return user_id, validated
def _sanitize_error_for_client(error: Exception, context: str = "") -> str:
"""
Return a safe, generic error message for clients.
Detailed error is logged internally but not exposed to clients to prevent
information leakage (database paths, API URLs, tokens, etc.).
Args:
error: The exception that occurred
context: Optional context for logging (e.g., "revoke_user_access")
Returns:
Generic error message safe for client consumption
"""
# Log detailed error for debugging
logger.error(f"Error in {context}: {error}", exc_info=True)
# Return generic message
return "An internal error occurred. Please contact your administrator."
def _parse_int_param(
value: str | None,
default: int,
min_val: int,
max_val: int,
param_name: str,
) -> int:
"""Parse and validate integer parameter."""
if value is None:
return default
try:
parsed = int(value)
except ValueError:
raise ValueError(f"Invalid {param_name}: must be an integer")
if parsed < min_val or parsed > max_val:
raise ValueError(
f"Invalid {param_name}: must be between {min_val} and {max_val}"
)
return parsed
def _parse_float_param(
value: Any,
default: float,
min_val: float,
max_val: float,
param_name: str,
) -> float:
"""Parse and validate float parameter."""
if value is None:
return default
try:
parsed = float(value)
except (ValueError, TypeError):
raise ValueError(f"Invalid {param_name}: must be a number")
if parsed < min_val or parsed > max_val:
raise ValueError(
f"Invalid {param_name}: must be between {min_val} and {max_val}"
)
return parsed
def _validate_query_string(query: str, max_length: int = 10000) -> None:
"""Validate query string length."""
if len(query) > max_length:
raise ValueError(f"Query too long: maximum {max_length} characters")
async def get_server_status(request: Request) -> JSONResponse:
"""GET /api/v1/status - Server status and version.
Returns basic server information including version, auth mode,
vector sync status, and uptime.
Public endpoint - no authentication required.
"""
# Public endpoint - no authentication required
# Get configuration
settings = get_settings()
# Calculate uptime
uptime_seconds = int(time.time() - _server_start_time)
# Determine auth mode using proper mode detection
mode = detect_auth_mode(settings)
# Map deployment mode to auth_mode for API response
# This helps clients (like Astrolabe) determine which auth flow to use
if mode == AuthMode.OAUTH_SINGLE_AUDIENCE or mode == AuthMode.OAUTH_TOKEN_EXCHANGE:
auth_mode = "oauth"
elif mode == AuthMode.MULTI_USER_BASIC:
auth_mode = "multi_user_basic"
elif mode == AuthMode.SINGLE_USER_BASIC:
auth_mode = "basic"
elif mode == AuthMode.SMITHERY_STATELESS:
auth_mode = "smithery"
else:
auth_mode = "unknown"
response_data = {
"version": __version__,
"auth_mode": auth_mode,
"vector_sync_enabled": settings.vector_sync_enabled,
"uptime_seconds": uptime_seconds,
"management_api_version": "1.0",
}
# Add app password support indicator for multi-user BasicAuth mode
if mode == AuthMode.MULTI_USER_BASIC:
response_data["supports_app_passwords"] = settings.enable_offline_access
# Include OIDC configuration for client discovery (e.g. Astrolabe PHP app).
# Always attempt to provide oidc.discovery_url so clients can discover the
# IdP regardless of the current auth mode. This enables smoother transitions
# between auth modes and lets Astrolabe pre-discover OIDC endpoints.
oidc_config: dict[str, str] = {}
if settings.oidc_discovery_url:
# Explicit OIDC_DISCOVERY_URL takes precedence
oidc_config["discovery_url"] = settings.oidc_discovery_url
elif settings.nextcloud_host:
# Auto-derive from NEXTCLOUD_HOST — Nextcloud exposes OIDC discovery
# at the standard well-known path when user_oidc is enabled
host = settings.nextcloud_host.rstrip("/")
oidc_config["discovery_url"] = f"{host}/.well-known/openid-configuration"
if settings.oidc_issuer:
oidc_config["issuer"] = settings.oidc_issuer
if oidc_config:
response_data["oidc"] = oidc_config
return JSONResponse(response_data)
async def get_vector_sync_status(request: Request) -> JSONResponse:
"""GET /api/v1/vector-sync/status - Vector sync metrics.
Returns real-time indexing status and metrics.
Requires: VECTOR_SYNC_ENABLED=true
Public endpoint - no authentication required.
"""
# Public endpoint - no authentication required
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
try:
# Get document receive stream from app state (set by starlette_lifespan in app.py)
document_receive_stream = getattr(
request.app.state, "document_receive_stream", None
)
if document_receive_stream is None:
logger.debug("document_receive_stream not available in app state")
return JSONResponse(
{
"status": "unknown",
"indexed_documents": 0,
"pending_documents": 0,
"message": "Vector sync stream not initialized",
}
)
# Get pending count from stream statistics
stream_stats = document_receive_stream.statistics()
pending_count = stream_stats.current_buffer_used
# Get Qdrant client and query indexed count
indexed_count = 0
try:
qdrant_client = await get_qdrant_client()
# Count documents in collection, excluding placeholders
count_result = await qdrant_client.count(
collection_name=settings.get_collection_name(),
count_filter=Filter(must=[get_placeholder_filter()]),
)
indexed_count = count_result.count
except Exception as e:
logger.warning(f"Failed to query Qdrant for indexed count: {e}")
# Continue with indexed_count = 0
# Determine status
status = "syncing" if pending_count > 0 else "idle"
return JSONResponse(
{
"status": status,
"indexed_documents": indexed_count,
"pending_documents": pending_count,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_vector_sync_status")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_user_session(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/session - User session details.
Returns information about the user's MCP session including:
- Background access status (offline_access)
- IdP profile information
Requires OAuth bearer token. The user_id in the path must match
the user_id in the token.
"""
try:
# Validate OAuth token and extract user
token_user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_user_session_auth")
return JSONResponse(
{"error": error_msg},
status_code=401,
)
# Get user_id from path
path_user_id = request.path_params.get("user_id")
# Verify token user matches requested user
if token_user_id != path_user_id:
logger.warning(
f"User {token_user_id} attempted to access session for {path_user_id}"
)
return JSONResponse(
{
"error": "Forbidden",
"message": "Cannot access another user's session",
},
status_code=403,
)
# 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
settings = get_settings()
enable_offline_access = settings.enable_offline_access
if not enable_offline_access:
# Offline access disabled - return minimal session info
return JSONResponse(
{
"session_id": token_user_id,
"background_access_granted": False,
}
)
# Get refresh token storage from app state
storage = request.app.state.oauth_context.get("storage")
if not storage:
logger.error("Refresh token storage not available in app state")
return JSONResponse(
{
"session_id": token_user_id,
"background_access_granted": False,
"error": "Storage not configured",
}
)
try:
# Check if user has refresh token stored
refresh_token_data = await storage.get_refresh_token(token_user_id)
if not refresh_token_data:
# No refresh token - user hasn't provisioned background access
return JSONResponse(
{
"session_id": token_user_id,
"background_access_granted": False,
}
)
# User has background access - get profile info
profile = await storage.get_user_profile(token_user_id)
response_data = {
"session_id": token_user_id,
"background_access_granted": True,
"background_access_details": {
"granted_at": refresh_token_data.get("created_at"),
"scopes": refresh_token_data.get("scope", "").split(),
},
}
if profile:
response_data["idp_profile"] = profile
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_user_session")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def revoke_user_access(request: Request) -> JSONResponse:
"""POST /api/v1/users/{user_id}/revoke - Revoke user's background access.
Deletes the user's stored refresh token, removing their offline access.
Requires OAuth bearer token. The user_id in the path must match
the user_id in the token.
"""
try:
# Validate OAuth token and extract user
token_user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/users/{{user_id}}/revoke: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "revoke_user_access"),
},
status_code=401,
)
# Get user_id from path
path_user_id = request.path_params.get("user_id")
# Verify token user matches requested user
if token_user_id != path_user_id:
logger.warning(
f"User {token_user_id} attempted to revoke access for {path_user_id}"
)
return JSONResponse(
{
"error": "Forbidden",
"message": "Cannot revoke another user's access",
},
status_code=403,
)
# Get token broker from app state
oauth_context = request.app.state.oauth_context
if oauth_context is None:
logger.error("OAuth context not initialized")
return JSONResponse(
{"error": "OAuth not enabled"},
status_code=500,
)
token_broker = oauth_context.get("token_broker")
if not token_broker:
logger.error("Token broker not available in app state")
return JSONResponse(
{"error": "Token broker not configured"},
status_code=500,
)
try:
# Delete refresh token from storage
await token_broker.storage.delete_refresh_token(token_user_id)
# CRITICAL: Invalidate all cached tokens for this user
await token_broker.cache.invalidate(token_user_id)
logger.info(
f"Revoked background access for user {token_user_id} (cache and storage cleared)"
)
return JSONResponse(
{
"success": True,
"message": f"Background access revoked for {token_user_id}",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "revoke_user_access")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
+441
View File
@@ -0,0 +1,441 @@
"""App password management API endpoints.
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- Store app passwords for background sync operations
- Check app password status
- Delete stored app passwords
Authentication is via BasicAuth with the user's Nextcloud credentials.
Passwords are validated against Nextcloud before being stored.
"""
import base64
import logging
import re
import time
from collections import defaultdict
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
APP_PASSWORD_PATTERN = re.compile(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
)
# Timeout for Nextcloud API validation requests (seconds)
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
# Rate limiting configuration for app password provisioning
# Limits: 5 attempts per user per hour
RATE_LIMIT_MAX_ATTEMPTS = 5
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
# In-memory rate limiter storage
# Structure: {user_id: [(timestamp, success), ...]}
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
"""Check if user is rate limited for app password operations.
Implements a sliding window rate limiter to prevent brute-force attacks
on the app password provisioning endpoint.
Args:
user_id: User identifier to check
Returns:
Tuple of (is_allowed, seconds_until_retry)
- is_allowed: True if request should be allowed
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
"""
current_time = time.time()
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
# Clean up old attempts outside the window
_rate_limit_attempts[user_id] = [
(ts, success)
for ts, success in _rate_limit_attempts[user_id]
if ts > window_start
]
# Count recent attempts (both successful and failed)
recent_attempts = len(_rate_limit_attempts[user_id])
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
# Find when the oldest attempt in the window will expire
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
seconds_until_retry = int(
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
)
return False, max(1, seconds_until_retry)
return True, 0
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
"""Record an app password provisioning attempt for rate limiting.
Args:
user_id: User identifier
success: Whether the attempt was successful
"""
_rate_limit_attempts[user_id].append((time.time(), success))
def _extract_basic_auth(
request: Request, path_user_id: str
) -> tuple[str, str, JSONResponse | None]:
"""Extract and validate BasicAuth credentials from request.
Validates:
1. Authorization header is present and valid BasicAuth format
2. Username in credentials matches the path user_id
Args:
request: Starlette request with Authorization header
path_user_id: User ID from the URL path to verify against
Returns:
Tuple of (username, password, error_response)
- If successful: (username, password, None)
- If failed: ("", "", JSONResponse with error)
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
return (
"",
"",
JSONResponse(
{"success": False, "error": "Missing BasicAuth credentials"},
status_code=401,
),
)
try:
# Decode BasicAuth
encoded = auth_header.split(" ", 1)[1]
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return (
"",
"",
JSONResponse(
{"success": False, "error": "Invalid BasicAuth format"},
status_code=401,
),
)
# Verify username matches path user_id
if username != path_user_id:
logger.warning(
f"Username mismatch in app password operation for path user {path_user_id}"
)
return (
"",
"",
JSONResponse(
{"success": False, "error": "Username does not match path user_id"},
status_code=403,
),
)
return username, password, None
async def _get_app_password_storage(request: Request) -> RefreshTokenStorage:
"""Get or initialize RefreshTokenStorage for app password operations.
Checks app.state.storage first, then falls back to creating from environment.
This helper avoids repeated storage initialization logic across endpoints.
Args:
request: Starlette request with app state
Returns:
Initialized RefreshTokenStorage instance
"""
storage = getattr(request.app.state, "storage", None)
if not storage:
# Multi-user BasicAuth mode may not have oauth_context
# Initialize storage from environment
storage = RefreshTokenStorage.from_env()
await storage.initialize()
return storage
async def provision_app_password(request: Request) -> JSONResponse:
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
for multi-user BasicAuth mode background sync.
The request must include BasicAuth credentials where:
- username: Nextcloud user ID (must match path user_id)
- password: The app password being provisioned
The MCP server validates the app password against Nextcloud before storing it.
This proves the user owns the password and has access to Nextcloud.
Security model:
- User identity is verified via BasicAuth against Nextcloud
- App password is encrypted before storage
- Only the user who owns the password can provision it
- Rate limited to prevent brute-force attacks
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Check rate limit before processing
is_allowed, retry_after = _check_rate_limit(path_user_id)
if not is_allowed:
logger.warning(
f"Rate limit exceeded for app password provisioning: {path_user_id}"
)
return JSONResponse(
{
"success": False,
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
# Extract and validate BasicAuth credentials
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
_record_rate_limit_attempt(path_user_id, success=False)
return error_response
# Validate app password format
if not APP_PASSWORD_PATTERN.match(app_password):
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password format"},
status_code=400,
)
# Get Nextcloud host from settings
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
logger.error("NEXTCLOUD_HOST not configured")
return JSONResponse(
{"success": False, "error": "Server not configured"},
status_code=500,
)
# Validate app password against Nextcloud
try:
async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
# Use OCS API to verify credentials
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, app_password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
logger.warning(
f"App password validation failed for user: HTTP {response.status_code}"
)
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password"},
status_code=401,
)
# Verify the user ID from response matches
data = response.json()
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
if ocs_user_id != username:
logger.warning("User ID mismatch in OCS response")
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "User ID mismatch"},
status_code=403,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate app password: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
# Parse optional scopes and username from request body
scopes = None
nc_username = None
try:
body = await request.json()
scopes = body.get("scopes") # list[str] | None
nc_username = body.get("username") # Nextcloud loginName
except Exception:
pass # No JSON body = legacy call without scopes
# Store the validated app password
try:
storage = await _get_app_password_storage(request)
await storage.store_app_password_with_scopes(
username, app_password, scopes=scopes, username=nc_username
)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password stored for {username}",
"scopes": scopes,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "provision_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def get_app_password_status(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
Returns status of background sync access for multi-user BasicAuth mode.
Requires BasicAuth with the user's app password for authentication.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
app_password = await storage.get_app_password(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"has_app_password": app_password is not None,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def delete_app_password(request: Request) -> JSONResponse:
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
Removes the user's app password from MCP server storage.
Requires BasicAuth with the user's credentials.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
# Validate credentials against Nextcloud
settings = get_settings()
nextcloud_host = settings.nextcloud_host
try:
async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
return JSONResponse(
{"success": False, "error": "Invalid credentials"},
status_code=401,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate credentials: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
try:
storage = await _get_app_password_storage(request)
deleted = await storage.delete_app_password(username)
if deleted:
logger.info(f"Deleted app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password deleted for {username}",
}
)
else:
return JSONResponse(
{
"success": True,
"message": "No app password found to delete",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "delete_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
+776
View File
@@ -0,0 +1,776 @@
"""Visualization API endpoints for search and PDF preview.
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
- Execute unified search with semantic/BM25/hybrid algorithms
- Execute vector search with PCA visualization coordinates
- Fetch chunk context with surrounding text
- Render PDF pages server-side (avoiding CSP/worker issues)
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import base64
import logging
from typing import Any
import pymupdf
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
validate_token_and_get_user,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding.service import get_embedding_service
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
logger = logging.getLogger(__name__)
async def unified_search(request: Request) -> JSONResponse:
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
Optimized search endpoint for the Nextcloud Unified Search provider
and other PHP app integrations. Returns results with metadata needed
for navigation to source documents.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 20, // max: 100
"offset": 0, // pagination offset
"include_pca": false, // optional PCA coordinates
"include_chunks": true // include text snippets
}
Response:
{
"results": [{
"id": "doc123",
"doc_type": "note",
"title": "Document Title",
"excerpt": "Matching text snippet...",
"score": 0.85,
"path": "/path/to/file.txt", // for files
"board_id": 1, // for deck cards
"card_id": 42
}],
"total_found": 150,
"algorithm_used": "hybrid"
}
Requires OAuth bearer token for user filtering.
"""
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
# Validate and parse parameters
try:
query = body.get("query", "")
_validate_query_string(query, max_length=10000)
limit = _parse_int_param(
str(body.get("limit")) if body.get("limit") is not None else None,
20,
1,
100,
"limit",
)
offset = _parse_int_param(
str(body.get("offset")) if body.get("offset") is not None else None,
0,
0,
1000000,
"offset",
)
score_threshold = _parse_float_param(
body.get("score_threshold"),
0.0,
0.0,
1.0,
"score_threshold",
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
include_pca = body.get("include_pca", False)
include_chunks = body.get("include_chunks", True)
doc_types = body.get("doc_types") # Optional filter
if not query:
return JSONResponse({"results": [], "total_found": 0})
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Request extra results to handle offset
search_limit = limit + offset
# Execute search
all_results = []
if doc_types and isinstance(doc_types, list):
for doc_type in doc_types:
if doc_type:
results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
doc_type=doc_type,
)
all_results.extend(results)
all_results.sort(key=lambda r: r.score, reverse=True)
else:
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
)
# Sort results by score (no deduplication - show all chunks)
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
# Calculate total and apply pagination
total_found = len(sorted_results)
paginated_results = sorted_results[offset : offset + limit]
# Format results for Unified Search
formatted_results = []
for result in paginated_results:
# Get document ID (prefer note_id for notes)
doc_id = result.id
if result.metadata and "note_id" in result.metadata:
doc_id = result.metadata["note_id"]
result_data: dict[str, Any] = {
"id": doc_id,
"doc_type": result.doc_type,
"title": result.title,
"score": result.score,
}
# Include excerpt/chunk if requested (full content, no truncation)
if include_chunks and result.excerpt:
result_data["excerpt"] = result.excerpt
# Include navigation metadata from result.metadata
if result.metadata:
# File path and mimetype for files
if "path" in result.metadata:
result_data["path"] = result.metadata["path"]
if "mime_type" in result.metadata:
result_data["mime_type"] = result.metadata["mime_type"]
# Deck card navigation
if "board_id" in result.metadata:
result_data["board_id"] = result.metadata["board_id"]
if "card_id" in result.metadata:
result_data["card_id"] = result.metadata["card_id"]
# Calendar event metadata
if "calendar_id" in result.metadata:
result_data["calendar_id"] = result.metadata["calendar_id"]
if "event_uid" in result.metadata:
result_data["event_uid"] = result.metadata["event_uid"]
# Add PDF page metadata
if result.page_number is not None:
result_data["page_number"] = result.page_number
if result.page_count is not None:
result_data["page_count"] = result.page_count
# Add chunk metadata (always present, defaults to 0 and 1)
result_data["chunk_index"] = result.chunk_index
result_data["total_chunks"] = result.total_chunks
# Add chunk offsets for modal navigation
if result.chunk_start_offset is not None:
result_data["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
result_data["chunk_end_offset"] = result.chunk_end_offset
formatted_results.append(result_data)
response_data: dict[str, Any] = {
"results": formatted_results,
"total_found": total_found,
"algorithm_used": algorithm,
}
# Optional PCA coordinates
if include_pca and len(paginated_results) >= 2:
try:
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(
paginated_results, query_embedding
)
response_data["pca_data"] = pca_data
except Exception as e:
logger.warning(f"Failed to compute PCA for unified search: {e}")
return JSONResponse(response_data)
except Exception as e:
logger.error(f"Error in unified search: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=500,
)
async def vector_search(request: Request) -> JSONResponse:
"""POST /api/v1/vector-viz/search - Vector search for visualization.
Executes semantic search and returns results with optional PCA coordinates
for 2D visualization.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 10, // max: 50
"include_pca": true, // whether to include 2D coordinates
"doc_types": ["note", "file"] // optional filter by document types
}
Requires OAuth bearer token for user filtering.
"""
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "vector_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
query = body.get("query", "")
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
score_threshold = body.get("score_threshold", 0.0)
limit = min(body.get("limit", 10), 50) # Enforce max limit
include_pca = body.get("include_pca", True)
doc_types = body.get("doc_types") # Optional list of document types
if not query:
return JSONResponse(
{"error": "Missing required parameter: query"},
status_code=400,
)
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
# which combines dense semantic and sparse BM25 vectors
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Execute search for each doc_type if specified, otherwise search all
all_results = []
if doc_types and isinstance(doc_types, list):
# Search each doc_type separately and merge results
for doc_type in doc_types:
if doc_type: # Skip empty strings
results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
doc_type=doc_type,
)
all_results.extend(results)
# Sort merged results by score and limit
all_results.sort(key=lambda r: r.score, reverse=True)
all_results = all_results[:limit]
else:
# Search all document types
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
)
# Format results for PHP client
formatted_results = []
for result in all_results:
formatted_result = {
"id": result.id,
"doc_type": result.doc_type,
"title": result.title,
"excerpt": result.excerpt[:200] if result.excerpt else "",
"score": result.score,
"metadata": result.metadata,
# Chunk information for context display
"chunk_index": result.chunk_index,
"total_chunks": result.total_chunks,
}
# Include optional fields if present
if result.chunk_start_offset is not None:
formatted_result["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
formatted_result["chunk_end_offset"] = result.chunk_end_offset
if result.page_number is not None:
formatted_result["page_number"] = result.page_number
if result.page_count is not None:
formatted_result["page_count"] = result.page_count
formatted_results.append(formatted_result)
response_data: dict[str, Any] = {
"results": formatted_results,
"algorithm_used": algorithm,
"total_documents": len(formatted_results),
}
# Compute PCA coordinates for visualization using shared function
if include_pca and len(all_results) >= 2:
try:
# Get query embedding from search algorithm or generate it
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(all_results, query_embedding)
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
response_data["query_coords"] = pca_data["query_coords"]
if "pca_variance" in pca_data:
response_data["pca_variance"] = pca_data["pca_variance"]
except Exception as e:
logger.warning(f"Failed to compute PCA coordinates: {e}")
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
elif include_pca:
# Not enough results for PCA
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "vector_search")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_chunk_context(request: Request) -> JSONResponse:
"""GET /api/v1/chunk-context - Fetch chunk text with context.
Retrieves the matched chunk along with surrounding text and metadata.
Used by clients to display chunk context and highlighted PDFs.
Query parameters:
doc_type: Document type (e.g., "note")
doc_id: Document ID
start: Chunk start offset (character position)
end: Chunk end offset (character position)
context: Characters of context before/after (default: 500)
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_chunk_context"),
},
status_code=401,
)
try:
# Get query parameters
doc_type = request.query_params.get("doc_type")
doc_id = request.query_params.get("doc_id")
start_str = request.query_params.get("start")
end_str = request.query_params.get("end")
# Validate required parameters
if not all([doc_type, doc_id, start_str, end_str]):
return JSONResponse(
{
"success": False,
"error": "Missing required parameters: doc_type, doc_id, start, end",
},
status_code=400,
)
# Type narrowing: we already checked these are not None above
assert start_str is not None
assert end_str is not None
assert doc_id is not None
assert doc_type is not None
# Parse and validate integer parameters with bounds checking
try:
context_chars = _parse_int_param(
request.query_params.get("context"),
500,
0,
10000,
"context_chars",
)
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
if end <= start:
raise ValueError("end must be greater than start")
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Convert doc_id to int if possible (most IDs are int)
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
# Get bearer token for client initialization
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Initialize authenticated Nextcloud client
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
chunk_context = await get_chunk_with_context(
nc_client=nc_client,
user_id=user_id,
doc_id=doc_id_val,
doc_type=doc_type,
chunk_start=start,
chunk_end=end,
context_chars=context_chars,
)
if chunk_context is None:
return JSONResponse(
{
"success": False,
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
},
status_code=404,
)
# For PDF files, also fetch the highlighted page image from Qdrant if available
# This is useful for clients that want to show a pre-rendered image
highlighted_page_image = None
page_number = chunk_context.page_number
if doc_type == "file":
try:
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Query for this specific chunk's highlighted image
points_response = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
get_placeholder_filter(),
FieldCondition(
key="doc_id", match=MatchValue(value=doc_id_val)
),
FieldCondition(
key="user_id", match=MatchValue(value=user_id)
),
FieldCondition(
key="chunk_start_offset", match=MatchValue(value=start)
),
FieldCondition(
key="chunk_end_offset", match=MatchValue(value=end)
),
]
),
limit=1,
with_vectors=False,
with_payload=["highlighted_page_image", "page_number"],
)
if points_response[0]:
payload = points_response[0][0].payload
if payload:
highlighted_page_image = payload.get("highlighted_page_image")
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
if payload.get("page_number") is not None:
page_number = payload.get("page_number")
except Exception as e:
logger.warning(f"Failed to fetch highlighted image: {e}")
# Build response
response_data = {
"success": True,
"chunk_text": chunk_context.chunk_text,
"before_context": chunk_context.before_context,
"after_context": chunk_context.after_context,
"has_more_before": chunk_context.has_before_truncation,
"has_more_after": chunk_context.has_after_truncation,
"page_number": page_number,
"chunk_index": chunk_context.chunk_index,
"total_chunks": chunk_context.total_chunks,
}
if highlighted_page_image:
response_data["highlighted_page_image"] = highlighted_page_image
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_pdf_preview(request: Request) -> JSONResponse:
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
to display PDF pages without requiring client-side PDF.js, avoiding CSP
worker restrictions and ES private field issues in Chromium.
Query parameters:
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
page: Page number (1-indexed, default: 1)
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
Returns:
{
"success": true,
"image": "<base64-encoded-png>",
"page_number": 1,
"total_pages": 10
}
Requires OAuth bearer token for authentication.
"""
# Log incoming request
file_path_param = request.query_params.get("file_path", "<not provided>")
page_param = request.query_params.get("page", "1")
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
logger.info(f"PDF preview authenticated for user: {user_id}")
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
return JSONResponse(
{
"success": False,
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
},
status_code=401,
)
try:
# Parse and validate parameters
file_path = request.query_params.get("file_path")
if not file_path:
return JSONResponse(
{"success": False, "error": "Missing required parameter: file_path"},
status_code=400,
)
# Validate no path traversal sequences
if ".." in file_path:
return JSONResponse(
{"success": False, "error": "Invalid file path"},
status_code=400,
)
try:
page_num = _parse_int_param(
request.query_params.get("page"), 1, 1, 10000, "page"
)
scale = _parse_float_param(
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
)
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Get bearer token for WebDAV authentication
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Download PDF via WebDAV using user's token
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
# Check file size limit (50 MB)
max_pdf_size = 50 * 1024 * 1024
if len(pdf_bytes) > max_pdf_size:
return JSONResponse(
{
"success": False,
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
},
status_code=413,
)
# Render page with PyMuPDF
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
try:
total_pages = doc.page_count
# Validate page number
if page_num > total_pages:
return JSONResponse(
{
"success": False,
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
},
status_code=400,
)
page = doc[page_num - 1] # 0-indexed
mat = pymupdf.Matrix(scale, scale)
pix = page.get_pixmap(matrix=mat, alpha=False)
png_bytes = pix.tobytes("png")
finally:
doc.close()
# Encode as base64
image_b64 = base64.b64encode(png_bytes).decode("ascii")
logger.info(
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
f"{len(png_bytes):,} bytes"
)
return JSONResponse(
{
"success": True,
"image": image_b64,
"page_number": page_num,
"total_pages": total_pages,
}
)
except FileNotFoundError:
logger.warning(f"PDF file not found: {file_path_param}")
return JSONResponse(
{"success": False, "error": "PDF file not found"},
status_code=404,
)
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
return JSONResponse(
{"success": False, "error": "Invalid or corrupted PDF file"},
status_code=400,
)
except Exception as e:
logger.error(f"PDF preview error: {e}", exc_info=True)
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
+304
View File
@@ -0,0 +1,304 @@
"""Webhook management API endpoints.
Provides REST API endpoints for managing webhook registrations with Nextcloud.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- List installed Nextcloud apps
- Create, list, and delete webhook registrations
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_sanitize_error_for_client,
extract_bearer_token,
validate_token_and_get_user,
)
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def get_installed_apps(request: Request) -> JSONResponse:
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
Returns a list of installed app IDs for filtering webhook presets.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=401,
)
try:
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Get installed apps using OCS API
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
# We check which ones are installed and enabled
ocs_url = "/ocs/v1.php/cloud/apps"
params = {"filter": "enabled"}
response = await client.get(
ocs_url,
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
if response.status_code != 200:
raise ValueError(f"OCS API returned status {response.status_code}")
data = response.json()
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
return JSONResponse({"apps": apps})
except Exception as e:
logger.error(f"Error getting installed apps for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=500,
)
async def list_webhooks(request: Request) -> JSONResponse:
"""GET /api/v1/webhooks - List all registered webhooks.
Returns list of webhook registrations for the authenticated user.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=401,
)
try:
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to list webhooks
webhooks_client = WebhooksClient(client, user_id)
webhooks = await webhooks_client.list_webhooks()
return JSONResponse({"webhooks": webhooks})
except Exception as e:
logger.error(f"Error listing webhooks for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=500,
)
async def create_webhook(request: Request) -> JSONResponse:
"""POST /api/v1/webhooks - Create a new webhook registration.
Request body:
{
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"uri": "http://mcp:8000/webhooks/nextcloud",
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
}
Returns the created webhook data including the webhook ID.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
event = body.get("event")
uri = body.get("uri")
# Accept both camelCase (eventFilter) and snake_case (event_filter)
event_filter = body.get("eventFilter") or body.get("event_filter")
if not event or not uri:
return JSONResponse(
{
"error": "Bad request",
"message": "Missing required fields: event, uri",
},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to create webhook
webhooks_client = WebhooksClient(client, user_id)
webhook_data = await webhooks_client.create_webhook(
event=event, uri=uri, event_filter=event_filter
)
return JSONResponse({"webhook": webhook_data})
except Exception as e:
logger.error(f"Error creating webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=500,
)
async def delete_webhook(request: Request) -> JSONResponse:
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
Returns success/failure status.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=401,
)
try:
# Get webhook_id from path parameter
webhook_id = request.path_params.get("webhook_id")
if not webhook_id:
return JSONResponse(
{"error": "Bad request", "message": "Missing webhook_id"},
status_code=400,
)
try:
webhook_id = int(webhook_id)
except ValueError:
return JSONResponse(
{"error": "Bad request", "message": "Invalid webhook_id"},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to delete webhook
webhooks_client = WebhooksClient(client, user_id)
await webhooks_client.delete_webhook(webhook_id=webhook_id)
return JSONResponse({"success": True, "message": "Webhook deleted"})
except Exception as e:
logger.error(f"Error deleting webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=500,
)
+1390 -390
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
"""
Client for querying Astrolabe Management API for background sync credentials.
This client uses OAuth client credentials flow to authenticate to Nextcloud
and retrieve user app passwords for background sync operations.
"""
import logging
import time
from typing import Optional
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
class AstrolabeClient:
"""Client for querying Astrolabe API for background sync credentials.
Uses OAuth client credentials flow to authenticate as the MCP server
and retrieve user app passwords that are stored in Nextcloud.
"""
def __init__(
self,
nextcloud_host: str,
client_id: str,
client_secret: str,
):
"""
Initialize Astrolabe client.
Args:
nextcloud_host: Nextcloud base URL (e.g., https://cloud.example.com)
client_id: OAuth client ID for MCP server
client_secret: OAuth client secret
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._token_cache: Optional[dict] = None # {access_token, expires_at}
async def get_access_token(self) -> str:
"""
Get access token using OAuth client credentials flow.
Tokens are cached with 1-minute early refresh to avoid expiration.
Returns:
Access token string
Raises:
httpx.HTTPError: If token request fails
"""
# Check cache
if self._token_cache and time.time() < self._token_cache["expires_at"]:
logger.debug("Using cached OAuth token for Astrolabe API")
return self._token_cache["access_token"]
# Discover token endpoint
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with nextcloud_httpx_client() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}")
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
token_endpoint = discovery_resp.json()["token_endpoint"]
logger.debug(f"Requesting client credentials token from {token_endpoint}")
# Request token using client credentials grant
token_resp = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "openid", # Minimal scope
},
)
token_resp.raise_for_status()
data = token_resp.json()
# Cache with 1-minute early refresh
expires_in = data.get("expires_in", 3600)
self._token_cache = {
"access_token": data["access_token"],
"expires_at": time.time() + expires_in - 60,
}
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
return data["access_token"]
async def get_user_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve user's app password for background sync.
Args:
user_id: Nextcloud user ID
Returns:
App password string, or None if user hasn't provisioned
Raises:
httpx.HTTPError: If API request fails (except 404)
"""
token = await self.get_access_token()
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with nextcloud_httpx_client() as client:
logger.debug(f"Retrieving app password for user: {user_id}")
response = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
if response.status_code == 404:
logger.debug(f"No app password configured for user: {user_id}")
return None
response.raise_for_status()
data = response.json()
logger.info(
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
)
return data.get("app_password")
async def get_background_sync_status(self, user_id: str) -> dict:
"""
Get background sync status for a user.
Args:
user_id: Nextcloud user ID
Returns:
Dict with keys: has_access, credential_type, provisioned_at
Raises:
httpx.HTTPError: If API request fails
"""
# For now, check if app password exists
# In the future, this could query a dedicated status endpoint
app_password = await self.get_user_app_password(user_id)
return {
"has_access": app_password is not None,
"credential_type": "app_password" if app_password else None,
"provisioned_at": None, # TODO: Get from API if available
}
@@ -8,8 +8,10 @@ import hashlib
import logging
import os
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
@@ -21,9 +23,31 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_query_idp_userinfo,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have secure flag.
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
Returns:
True if cookies should be secure (HTTPS), False otherwise
"""
# Explicit configuration takes precedence
explicit = os.getenv("COOKIE_SECURE", "").lower()
if explicit == "true":
return True
if explicit == "false":
return False
# Auto-detect from NEXTCLOUD_HOST protocol
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
return nextcloud_host.startswith("https://")
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"""Browser OAuth login endpoint - redirects to IdP for authentication.
@@ -50,6 +74,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
# Get redirect URL from query params (default to /app)
next_url = request.query_params.get("next", "/app")
logger.info(f"oauth_login - next_url: {next_url}")
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
@@ -71,7 +99,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
await storage.store_oauth_session(
session_id=state, # Use state as session ID
client_id="browser-ui",
client_redirect_uri="/app",
client_redirect_uri=next_url, # Store the redirect URL for after auth
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
@@ -85,6 +113,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
if not oauth_client.authorization_endpoint:
await oauth_client.discover()
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
nextcloud_resource_uri = oauth_config.get(
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
)
idp_params = {
"client_id": oauth_client.client_id,
"redirect_uri": callback_uri,
@@ -94,6 +127,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
@@ -111,7 +145,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
)
# Fetch authorization endpoint
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -120,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)
@@ -131,6 +163,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
nextcloud_resource_uri = oauth_config.get(
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
)
idp_params = {
"client_id": oauth_config["client_id"],
"redirect_uri": callback_uri,
@@ -140,6 +177,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
# Debug: Log full parameters
@@ -214,12 +252,15 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Retrieve code_verifier from session storage (PKCE required for all modes)
# Retrieve code_verifier and redirect URL from session storage
code_verifier = ""
next_url = "/app" # Default redirect
oauth_session = await storage.get_oauth_session(state)
if oauth_session:
# code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "")
# next_url was stored in client_redirect_uri field
next_url = oauth_session.get("client_redirect_uri", "/app")
# Clean up the temporary session
# Note: We don't have delete_oauth_session method, but it will expire after TTL
@@ -246,7 +287,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
oauth_client.token_endpoint,
data=token_params,
@@ -256,7 +297,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -274,7 +315,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with httpx.AsyncClient() as http_client:
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
token_endpoint,
data=token_params,
@@ -338,16 +379,33 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
user_id = f"user-{secrets.token_hex(8)}"
username = "unknown"
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
)
# Extract granted scopes
granted_scopes = (
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Store refresh token (for background jobs ONLY)
if refresh_token:
logger.info(f"Storing refresh token for user_id: {user_id}")
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
logger.info(f" Granted scopes: {granted_scopes}")
logger.info(f" Expires at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=None,
expires_at=refresh_expires_at,
flow_type="browser", # Browser-based login flow
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
)
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
logger.info(
@@ -383,13 +441,14 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
response = RedirectResponse("/app", status_code=302)
# Redirect to stored next_url (from OAuth session) or /app as default
response = RedirectResponse(next_url, status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
max_age=86400 * 30, # 30 days
httponly=True,
secure=False, # Set to True in production with HTTPS
secure=_should_use_secure_cookies(),
samesite="lax",
)
@@ -10,6 +10,8 @@ import httpx
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -81,6 +83,7 @@ async def register_client(
scopes: str = "openid profile email",
token_type: str | None = "Bearer",
resource_url: str | None = None,
max_retries: int = 3,
) -> ClientInfo:
"""
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
@@ -96,6 +99,7 @@ async def register_client(
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
Set to None to omit this field (required for Keycloak and other standard providers).
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
ClientInfo with registration details
@@ -132,58 +136,92 @@ async def register_client(
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
async with nextcloud_httpx_client(timeout=30.0) as client:
for attempt in range(max_retries):
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
if response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt)
logger.warning(
f"Rate limited (429) registering client, "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning(
"RFC 7592 fields missing - client deletion may not work"
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get(
"registration_access_token"
),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
logger.error(
f"Failed to register client: HTTP {e.response.status_code}"
)
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(
f"Invalid response from registration endpoint: missing {e}"
)
raise ValueError(f"Invalid registration response: missing {e}")
# Should not reach here, but raise if we do
raise httpx.HTTPStatusError(
"Registration failed after retries",
request=httpx.Request("POST", registration_endpoint),
response=httpx.Response(429),
)
async def delete_client(
@@ -229,7 +267,7 @@ async def delete_client(
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
async with nextcloud_httpx_client(timeout=30.0) as http_client:
for attempt in range(max_retries):
try:
# Prefer RFC 7592 Bearer token authentication
+26 -4
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__)
@@ -141,8 +142,8 @@ class ClientRegistry:
if not self._validate_redirect_uri(client, redirect_uri):
return False, f"Invalid redirect_uri for client {client_id}"
# Validate scopes if provided
if scopes:
# Validate scopes if provided (wildcard "*" allows all scopes)
if scopes and "*" not in client.allowed_scopes:
invalid_scopes = set(scopes) - set(client.allowed_scopes)
if invalid_scopes:
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
@@ -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
@@ -203,6 +202,29 @@ class ClientRegistry:
# In production, would persist to database
return True
def register_proxy_client(
self, client_id: str, redirect_uris: list[str], name: str = ""
) -> None:
"""Register a client discovered via DCR proxy.
When the MCP server acts as an OAuth AS proxy, clients register via
the proxy's /oauth/register endpoint. This method stores the client
locally so /oauth/authorize can validate it.
Args:
client_id: Client identifier from Nextcloud DCR response
redirect_uris: Allowed redirect URIs
name: Optional human-readable name
"""
self._clients[client_id] = MCPClientInfo(
client_id=client_id,
name=name or f"DCR-{client_id[:8]}",
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True,
)
logger.info(f"Registered proxy client: {client_id}")
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
"""
Get client information.
+88
View File
@@ -0,0 +1,88 @@
"""MCP elicitation helpers for Login Flow v2.
Provides a unified way to present login URLs to users, using MCP elicitation
when the client supports it, or falling back to returning the URL in a message.
"""
import logging
from mcp.server.fastmcp import Context
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class LoginFlowConfirmation(BaseModel):
"""Schema for Login Flow v2 confirmation elicitation."""
acknowledged: bool = Field(
default=False,
description="Check this box after completing login at the provided URL",
)
async def present_login_url(
ctx: Context,
login_url: str,
message: str | None = None,
) -> str:
"""Present a login URL to the user via MCP elicitation or message.
Tries MCP elicitation first (ctx.elicit) for interactive clients.
Falls back to returning the URL as a plain message.
Args:
ctx: MCP context
login_url: URL the user should open in their browser
message: Optional custom message (defaults to standard Login Flow prompt)
Returns:
"accepted" if user acknowledged via elicitation,
"declined" if user declined,
"message_only" if elicitation not supported (URL returned in message)
"""
if message is None:
message = (
f"Please log in to Nextcloud to grant access:\n\n"
f"{login_url}\n\n"
f"Open this URL in your browser, log in, and grant the requested permissions. "
f"Then check the box below and click OK."
)
if not hasattr(ctx, "elicit"):
logger.debug(
"Elicitation not available (no elicit method), returning URL in message"
)
return "message_only"
try:
result = await ctx.elicit(
message=message,
schema=LoginFlowConfirmation,
)
if result.action == "accept":
if hasattr(result, "data") and not result.data.acknowledged: # type: ignore[union-attr]
logger.warning(
"User accepted login flow without checking the acknowledged box — "
"login completion will be verified via polling"
)
logger.info("User acknowledged login flow completion")
return "accepted"
elif result.action == "decline":
logger.info("User declined login flow")
return "declined"
else:
logger.info("User cancelled login flow")
return "cancelled"
except NotImplementedError:
# Elicitation not supported by this client/SDK - fall back to message
logger.debug("Elicitation not available, returning URL in message")
return "message_only"
except Exception as e:
logger.warning(
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
"falling back to message"
)
return "message_only"
+4 -2
View File
@@ -8,6 +8,7 @@ Handles OAuth flows with Keycloak as the identity provider, including:
- Integration with RefreshTokenStorage
"""
import base64
import hashlib
import logging
import os
@@ -17,6 +18,8 @@ from urllib.parse import urlencode, urlparse
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -106,7 +109,7 @@ class KeycloakOAuthClient:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=30.0)
self._http_client = nextcloud_httpx_client(timeout=30.0)
return self._http_client
async def close(self) -> None:
@@ -155,7 +158,6 @@ class KeycloakOAuthClient:
Returns:
Tuple of (code_verifier, code_challenge)
"""
import base64
# Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)
+157
View File
@@ -0,0 +1,157 @@
"""Nextcloud Login Flow v2 HTTP client.
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
The flow has two steps:
1. Initiate: POST /index.php/login/v2 returns login URL + poll endpoint/token
2. Poll: POST to poll endpoint with token returns server URL, loginName, appPassword
"""
import logging
import ssl
from pydantic import BaseModel, Field
from nextcloud_mcp_server.http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
class LoginFlowInitResponse(BaseModel):
"""Response from initiating Login Flow v2."""
login_url: str = Field(description="URL to present to the user for browser login")
poll_endpoint: str = Field(description="URL to poll for flow completion")
poll_token: str = Field(description="Token to use when polling")
class LoginFlowPollResult(BaseModel):
"""Result of polling Login Flow v2."""
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
login_name: str | None = Field(
None, description="Nextcloud login name (on completion)"
)
app_password: str | None = Field(
None, description="Generated app password (on completion)"
)
class LoginFlowV2Client:
"""HTTP client for Nextcloud Login Flow v2.
This client handles the two-step Login Flow v2 process:
1. Initiate a flow to get a login URL for the user
2. Poll for completion to receive the app password
Args:
nextcloud_host: Base URL of the Nextcloud instance
verify_ssl: SSL verification setting (True, False, or SSLContext)
"""
def __init__(
self,
nextcloud_host: str,
verify_ssl: bool | ssl.SSLContext = True,
):
self.nextcloud_host = nextcloud_host.rstrip("/")
self.verify_ssl = verify_ssl
async def initiate(
self, user_agent: str = "nextcloud-mcp-server"
) -> LoginFlowInitResponse:
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to POST /index.php/login/v2 on the
configured Nextcloud server to start a new login flow.
Args:
user_agent: User-Agent string for the app password name
Returns:
LoginFlowInitResponse with login URL and poll credentials
Raises:
httpx.HTTPStatusError: If the Nextcloud server returns an error
"""
url = f"{self.nextcloud_host}/index.php/login/v2"
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=15.0
) as client:
response = await client.post(
url,
headers={"User-Agent": user_agent},
)
response.raise_for_status()
data = response.json()
poll_data = data.get("poll", {})
try:
result = LoginFlowInitResponse(
login_url=data["login"],
poll_endpoint=poll_data["endpoint"],
poll_token=poll_data["token"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
) from e
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
return result
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to the poll endpoint provided by the
initiate response. Nextcloud returns:
- 200 with credentials when the user completes login
- 404 when still pending
- Other errors for expired/invalid flows
Args:
poll_endpoint: URL to poll (from initiate response)
poll_token: Token for polling (from initiate response)
Returns:
LoginFlowPollResult with status and optional credentials
"""
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=10.0
) as client:
response = await client.post(
poll_endpoint,
data={"token": poll_token},
)
if response.status_code == 200:
data = response.json()
logger.info(
f"Login Flow v2 completed: server={data.get('server')}, "
f"loginName={data.get('loginName')}"
)
try:
return LoginFlowPollResult(
status="completed",
server=data["server"],
login_name=data["loginName"],
app_password=data["appPassword"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
) from e
if response.status_code == 404:
logger.debug("Login Flow v2 still pending")
return LoginFlowPollResult(status="pending")
# Any other status indicates the flow has expired or is invalid
logger.warning(
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
)
return LoginFlowPollResult(status="expired")
File diff suppressed because it is too large Load Diff
@@ -9,11 +9,13 @@ import functools
import logging
from typing import Callable
import jwt
from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -65,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
@@ -78,8 +78,6 @@ def require_provisioning(func: Callable) -> Callable:
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
try:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -163,8 +161,6 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -1,7 +1,7 @@
"""Scope-based authorization for MCP tools."""
import logging
import os
import time
from functools import wraps
from typing import Any, Callable
@@ -10,8 +10,18 @@ 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.auth.storage import get_shared_storage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
# Scopes that only assert identity (OIDC standard claims).
# Tools requiring *only* these scopes (e.g. auth provisioning tools) must
# bypass the Login Flow v2 "is the user provisioned?" check — otherwise the
# very tools that *create* app passwords would be blocked for unprovisioned
# users, creating a circular dependency.
IDENTITY_ONLY_SCOPES: frozenset[str] = frozenset({"openid", "profile", "email"})
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
@@ -119,21 +129,70 @@ def require_scopes(*required_scopes: str):
)
if access_token is None:
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
# No OAuth token — BasicAuth mode bypasses scope checks
logger.debug(
f"No access token present for {func_name} - allowing (BasicAuth mode)"
f"No access token for {func_name} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# ── Login Flow v2: Check stored app password scopes ──
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
# identity only. Nextcloud API access uses stored app passwords.
# Check if the user has a stored app password with appropriate scopes.
if get_settings().enable_login_flow and not set(required_scopes).issubset(
IDENTITY_ONLY_SCOPES
):
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
user_id = await extract_user_id_from_token(ctx)
if user_id and user_id != "default_user":
stored_scopes = await _get_stored_scopes(user_id)
if stored_scopes is None:
# No stored app password → require provisioning
error_msg = (
f"Access denied to {func_name}: "
f"Nextcloud access not provisioned. "
f"Please call 'nc_auth_provision_access' first."
)
logger.warning(error_msg)
raise ProvisioningRequiredError(error_msg)
if stored_scopes == "all":
# NULL scopes in DB = legacy app password = all allowed
logger.debug(
f"Stored app password scope check passed for {func_name}: all scopes"
)
return await func(*args, **kwargs)
# Check stored scopes against required
stored_set = set(stored_scopes)
missing = set(required_scopes) - stored_set
if missing:
error_msg = (
f"Access denied to {func_name}: "
f"Missing scopes: {', '.join(sorted(missing))}. "
f"Call 'nc_auth_update_scopes' to add permissions."
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing), error_msg)
logger.debug(
f"Stored app password scope check passed for {func_name}"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
# Check if offline access is enabled
enable_offline_access = (
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
)
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
settings = get_settings()
enable_offline_access = settings.enable_offline_access
# In offline access mode, check if Nextcloud scopes require provisioning
if enable_offline_access:
@@ -414,3 +473,47 @@ def discover_all_scopes(mcp) -> list[str]:
# Return sorted list of unique scopes
return sorted(all_scopes)
# ── Login Flow v2 helpers ────────────────────────────────────────────────
# Scope cache: user_id → (expires_at, scopes)
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
_SCOPE_CACHE_TTL = 300 # 5 minutes
def invalidate_scope_cache(user_id: str) -> None:
"""Remove cached scopes for a user (call when scopes are updated)."""
_scope_cache.pop(user_id, None)
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
"""Look up stored app password scopes for a user (with TTL cache).
Returns:
- list[str]: Specific scopes granted
- "all": NULL scopes in DB (legacy = all allowed)
- None: No stored app password (provisioning required)
Raises:
Storage/infrastructure exceptions propagate to the caller
(require_scopes decorator) for proper MCP error responses.
"""
now = time.time()
if user_id in _scope_cache:
expires_at, cached = _scope_cache[user_id]
if now < expires_at:
return cached
storage = await get_shared_storage()
data = await storage.get_app_password_with_scopes(user_id)
if data is None:
result = None
elif data["scopes"] is None:
result = "all"
else:
result = data["scopes"]
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
return result
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,219 @@
.viz-layout {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.viz-card {
background: var(--color-main-background);
border-radius: 0;
padding: 16px;
box-shadow: none;
}
.viz-controls-card {
flex: 0 0 auto;
border-bottom: 1px solid var(--color-border);
padding-bottom: 16px;
}
.viz-controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
align-items: end;
}
@media (min-width: 768px) {
.viz-controls-grid {
grid-template-columns: 2fr 1.5fr 1.5fr auto auto;
}
}
.viz-control-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.viz-control-group label {
font-weight: 500;
color: var(--color-main-text);
font-size: 13px;
}
.viz-control-group input[type="text"],
.viz-control-group input[type="number"],
.viz-control-group select {
width: 100%;
padding: 7px 10px;
border: 1px solid var(--color-border-dark);
border-radius: var(--border-radius);
font-size: 14px;
background: var(--color-main-background);
color: var(--color-main-text);
}
.viz-control-group input:focus,
.viz-control-group select:focus {
outline: none;
border-color: var(--color-primary-element);
}
.viz-control-group input[type="range"] {
width: 100%;
}
.viz-control-group select[multiple] {
min-height: 100px;
}
.viz-weight-display {
display: inline-block;
min-width: 40px;
text-align: right;
color: #666;
}
.viz-btn {
background: var(--color-primary-element);
color: white;
border: none;
padding: 7px 16px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 14px;
font-weight: 500;
white-space: nowrap;
}
.viz-btn:hover {
background: #0052a3;
}
.viz-btn-secondary {
background: #6c757d;
color: white;
border: none;
padding: 7px 16px;
border-radius: var(--border-radius);
cursor: pointer;
font-size: 14px;
white-space: nowrap;
}
.viz-btn-secondary:hover {
background: #5a6268;
}
.viz-card-plot {
flex: 0 0 auto;
display: flex;
flex-direction: column;
min-height: 500px;
height: 600px;
/* Remove horizontal padding to extend to full viewport width */
padding-left: 0;
padding-right: 0;
margin-left: -16px;
margin-right: -16px;
}
#viz-plot-container {
width: 100%;
height: 100%;
position: relative;
overflow: visible;
}
#viz-plot {
width: 100%;
height: 100%;
}
.viz-loading {
text-align: center;
padding: 40px;
color: #666;
}
.viz-loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: white;
color: #666;
}
.viz-no-results {
text-align: center;
padding: 40px;
color: #666;
font-style: italic;
}
.viz-advanced-section {
margin-top: 12px;
padding: 12px;
background: var(--color-background-hover);
border-radius: var(--border-radius);
border: 1px solid var(--color-border);
}
.viz-info-box {
background: var(--color-primary-element-light);
border-left: 3px solid var(--color-primary-element);
padding: 10px 12px;
margin-bottom: 16px;
font-size: 13px;
color: var(--color-main-text);
}
.chunk-toggle-btn {
background: #6c757d;
color: white;
border: none;
padding: 4px 10px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
margin-top: 6px;
}
.chunk-toggle-btn:hover {
background: #5a6268;
}
.chunk-context {
background: var(--color-background-hover);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 12px;
margin-top: 8px;
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
}
.chunk-text {
color: var(--color-text-maxcontrast);
}
.chunk-matched {
background: #fff3cd;
border: 1px solid #ffc107;
padding: 2px 4px;
border-radius: var(--border-radius);
font-weight: 500;
color: var(--color-main-text);
}
.chunk-ellipsis {
color: var(--color-text-maxcontrast);
font-style: italic;
}
/* PDF highlighted image styles */
.chunk-image-container {
margin-bottom: 16px;
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
overflow: hidden;
background: #fff;
}
.chunk-image-header {
background: var(--color-background-dark);
padding: 8px 12px;
font-size: 12px;
font-weight: 500;
color: var(--color-text-maxcontrast);
border-bottom: 1px solid var(--color-border);
font-family: var(--font-face);
}
.chunk-highlighted-image {
display: block;
max-width: 100%;
height: auto;
cursor: zoom-in;
}
.chunk-highlighted-image:hover {
opacity: 0.95;
}
@@ -0,0 +1,260 @@
// Initialize vizApp for vector visualization
function vizApp() {
return {
query: '',
algorithm: 'bm25_hybrid',
fusion: 'rrf',
showAdvanced: false,
showQueryPoint: true,
docTypes: [''],
limit: 50,
scoreThreshold: 0.0,
loading: false,
results: [],
coordinates: null,
queryCoords: null,
expandedChunks: {},
chunkLoading: {},
init() {
// Set up window resize listener to resize plot
window.addEventListener('resize', () => {
if (this.coordinates && this.results.length > 0) {
Plotly.Plots.resize('viz-plot');
}
});
},
async executeSearch() {
this.loading = true;
this.results = [];
try {
const params = new URLSearchParams({
query: this.query,
algorithm: this.algorithm,
limit: this.limit,
score_threshold: this.scoreThreshold,
});
if (this.algorithm === 'bm25_hybrid') {
params.append('fusion', this.fusion);
}
const selectedTypes = this.docTypes.filter(t => t !== '');
if (selectedTypes.length > 0) {
params.append('doc_types', selectedTypes.join(','));
}
const response = await fetch(`/app/vector-viz/search?${params}`);
const data = await response.json();
if (data.success) {
this.results = data.results;
this.coordinates = data.coordinates_3d;
this.queryCoords = data.query_coords;
this.renderPlot(this.coordinates, this.queryCoords, this.results);
} else {
alert('Search failed: ' + data.error);
}
} catch (error) {
alert('Error: ' + error.message);
} finally {
this.loading = false;
}
},
updatePlot() {
// Toggle query point visibility without recreating the plot
// This preserves camera position naturally since layout is untouched
if (this.coordinates && this.queryCoords && this.results.length > 0) {
const plotDiv = document.getElementById('viz-plot');
// If plot exists, just toggle the query trace visibility
if (plotDiv && plotDiv.data && plotDiv.data.length >= 2) {
// Trace index 1 is the query point
Plotly.restyle('viz-plot', { visible: this.showQueryPoint }, [1]);
} else {
// Plot doesn't exist yet, render it
this.renderPlot(this.coordinates, this.queryCoords, this.results);
}
}
},
renderPlot(coordinates, queryCoords, results) {
// Get container dimensions before creating layout
const container = document.getElementById('viz-plot-container');
const width = container.clientWidth;
const height = container.clientHeight;
const scores = results.map(r => r.score);
// Trace 1: Document results (always visible)
const documentTrace = {
x: coordinates.map(c => c[0]),
y: coordinates.map(c => c[1]),
z: coordinates.map(c => c[2]),
mode: 'markers',
type: 'scatter3d',
name: 'Documents',
visible: true,
customdata: results.map((r, i) => ({
title: r.title,
raw_score: r.original_score,
relative_score: r.score,
x: coordinates[i][0],
y: coordinates[i][1],
z: coordinates[i][2]
})),
hovertemplate:
'<b>%{customdata.title}</b><br>' +
'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)<br>' +
'(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})' +
'<extra></extra>',
marker: {
size: results.map(r => 4 + (Math.pow(r.score, 2) * 10)),
opacity: results.map(r => 0.3 + (r.score * 0.7)),
color: scores,
colorscale: 'Viridis',
showscale: true,
colorbar: {
title: 'Relative Score',
x: 1.02,
xanchor: 'left',
thickness: 20,
len: 0.8
},
cmin: 0,
cmax: 1
}
};
// Trace 2: Query point (visibility controlled by toggle)
const queryTrace = {
x: [queryCoords[0]],
y: [queryCoords[1]],
z: [queryCoords[2]],
mode: 'markers',
type: 'scatter3d',
name: 'Query',
visible: this.showQueryPoint, // Initial visibility from state
hovertemplate:
'<b>Search Query</b><br>' +
`(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})` +
'<extra></extra>',
marker: {
size: 10,
color: '#ef5350', // Subdued red (Material Design Red 400)
line: {
color: '#c62828', // Darker red border (Material Design Red 800)
width: 1
}
}
};
const layout = {
title: `Vector Space (PCA 3D) - ${results.length} results`,
width: width, // Explicit width from container
height: height, // Explicit height from container
scene: {
xaxis: { title: 'PC1' },
yaxis: { title: 'PC2' },
zaxis: { title: 'PC3' },
camera: {
eye: { x: 1.5, y: 1.5, z: 1.5 }
},
// Full width for 3D scene
domain: {
x: [0, 1],
y: [0, 1]
}
},
hovermode: 'closest',
autosize: true, // Enable auto-sizing for window resizes
showlegend: false, // Hide legend
margin: { l: 0, r: 100, t: 40, b: 0 } // Right margin for colorbar
};
// Always render both traces - visibility is controlled by the visible property
const traces = [documentTrace, queryTrace];
// Enable responsive resizing
const config = {
responsive: true,
displayModeBar: true
};
// Use newPlot() with explicit dimensions - renders at correct size immediately
// Camera position will be preserved by subsequent Plotly.restyle() calls in updatePlot()
Plotly.newPlot('viz-plot', traces, layout, config);
},
getNextcloudUrl(result) {
// Use global NEXTCLOUD_BASE_URL if set, otherwise construct from window location
const baseUrl = window.NEXTCLOUD_BASE_URL || '';
switch (result.doc_type) {
case 'note':
return `${baseUrl}/apps/notes/note/${result.id}`;
case 'file':
return `${baseUrl}/apps/files/?fileId=${result.id}`;
case 'calendar':
return `${baseUrl}/apps/calendar`;
case 'contact':
return `${baseUrl}/apps/contacts`;
case 'deck_card':
// URL pattern: /apps/deck/board/:boardId/card/:cardId
if (result.metadata && result.metadata.board_id) {
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
}
// Fallback if board_id not available
return `${baseUrl}/apps/deck`;
case 'news_item':
return `${baseUrl}/apps/news/item/${result.id}`;
default:
return `${baseUrl}`;
}
},
hasChunkPosition(result) {
return result.chunk_start_offset != null && result.chunk_end_offset != null;
},
isChunkExpanded(resultKey) {
return this.expandedChunks[resultKey] !== undefined;
},
async toggleChunk(result) {
const resultKey = `${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`;
if (this.isChunkExpanded(resultKey)) {
delete this.expandedChunks[resultKey];
return;
}
this.chunkLoading[resultKey] = true;
try {
const params = new URLSearchParams({
doc_type: result.doc_type,
doc_id: result.id,
start: result.chunk_start_offset,
end: result.chunk_end_offset,
context: 500
});
const response = await fetch(`/app/chunk-context?${params}`);
const data = await response.json();
if (data.success) {
this.expandedChunks[resultKey] = data;
} else {
alert('Failed to load chunk: ' + data.error);
}
} catch (error) {
alert('Error loading chunk: ' + error.message);
} finally {
delete this.chunkLoading[resultKey];
}
}
};
}
+719 -125
View File
@@ -28,13 +28,18 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
import json
import logging
import os
import socket
import time
from pathlib import Path
from typing import Any, Optional
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__)
@@ -117,7 +122,14 @@ class RefreshTokenStorage:
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
"""Initialize database schema"""
"""
Initialize database schema using Alembic migrations.
This method handles three scenarios:
1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version
"""
if self._initialized:
return
@@ -125,137 +137,55 @@ class RefreshTokenStorage:
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Set restrictive permissions on database file
# Set restrictive permissions on database file if it exists
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
# Check database state and run appropriate migration strategy
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
provisioned_at INTEGER, -- When Flow 2 was completed
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
scopes TEXT, -- JSON array of granted scopes
-- Browser session profile cache
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
profile_cached_at INTEGER -- When profile was last cached
# Check if database is managed by Alembic
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
)
has_alembic = await cursor.fetchone() is not None
if not has_alembic:
# Check if this is a pre-Alembic database with existing schema
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
)
"""
)
has_schema = await cursor.fetchone() is not None
await db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
if has_schema:
logger.info(
f"Detected pre-Alembic database at {self.db_path}, "
"stamping with initial revision"
)
else:
logger.info(
f"Initializing new database at {self.db_path} with migrations"
)
# Run migrations in a worker thread using anyio.to_thread
# This allows Alembic to run its own async operations in a separate context
if not has_alembic:
if has_schema:
# Stamp existing database without running migrations
await to_thread.run_sync(stamp_database, self.db_path, "001")
logger.info(
"Pre-Alembic database stamped successfully. "
"Future schema changes will use migrations."
)
"""
)
else:
# New database - run migrations
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database initialized with migrations")
else:
# Alembic-managed database - upgrade to latest
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database upgraded to latest version")
# Create index on audit logs for efficient queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
"ON audit_logs(user_id, timestamp)"
)
# OAuth client credentials storage
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# OAuth flow sessions (ADR-004 Progressive Consent)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_sessions (
session_id TEXT PRIMARY KEY,
client_id TEXT,
client_redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
mcp_authorization_code TEXT UNIQUE,
idp_access_token TEXT,
idp_refresh_token TEXT,
user_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
requested_scopes TEXT, -- JSON array of requested scopes
granted_scopes TEXT, -- JSON array of granted scopes
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
)
"""
)
# Create index for MCP authorization code lookups
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
"ON oauth_sessions(mcp_authorization_code)"
)
# Schema version tracking
await db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
"""
)
# Registered webhooks tracking (both BasicAuth and OAuth modes)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS registered_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL UNIQUE,
preset_id TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
# Create indexes for efficient webhook queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
"ON registered_webhooks(preset_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
"ON registered_webhooks(created_at)"
)
await db.commit()
# Set restrictive permissions after creation
# Set restrictive permissions after initialization
os.chmod(self.db_path, 0o600)
self._initialized = True
@@ -287,6 +217,8 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
scopes_json = json.dumps(scopes) if scopes else None
@@ -432,6 +364,9 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
@@ -516,6 +451,9 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
@@ -687,6 +625,9 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
encrypted_reg_token = (
@@ -757,6 +698,9 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
@@ -887,7 +831,6 @@ class RefreshTokenStorage:
resource_id: Resource identifier
auth_method: Authentication method used
"""
import socket
hostname = socket.gethostname()
timestamp = int(time.time())
@@ -1297,6 +1240,657 @@ class RefreshTokenStorage:
return deleted
# ============================================================================
# App Password Storage (multi-user BasicAuth mode)
# ============================================================================
async def store_app_password(
self,
user_id: str,
app_password: str,
) -> None:
"""
Store encrypted app password for background sync (multi-user BasicAuth mode).
Args:
user_id: Nextcloud user ID
app_password: Nextcloud app password to store
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
encrypted_password = self.cipher.encrypt(app_password.encode())
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?
)
""",
(user_id, encrypted_password, user_id, now, now),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored app password for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_app_password",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
Decrypted app password, or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password = row[0]
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
logger.debug(f"Retrieved app password for user {user_id}")
return decrypted_password
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
return None
async def delete_app_password(self, user_id: str) -> bool:
"""
Delete app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
True if password was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM app_passwords WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted app password for user {user_id}")
await self._audit_log(
event="delete_app_password",
user_id=user_id,
auth_method="app_password",
)
else:
logger.debug(f"No app password to delete for user {user_id}")
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def get_all_app_password_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored app passwords.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with app passwords")
return user_ids
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
"""
Validate stored app passwords against Nextcloud and remove invalid ones.
Makes a lightweight OCS request for each stored user to check if credentials
are still valid. Removes entries that return 401/403.
Args:
nextcloud_host: Nextcloud base URL
Returns:
List of user IDs whose app passwords were removed
"""
if not self._initialized:
await self.initialize()
user_ids = await self.get_all_app_password_user_ids()
if not user_ids:
return []
removed: list[str] = []
async def _validate_user(user_id: str) -> None:
app_password = await self.get_app_password(user_id)
if not app_password:
return
try:
async with httpx.AsyncClient(
base_url=nextcloud_host,
auth=httpx.BasicAuth(user_id, app_password),
timeout=10.0,
) as client:
response = await client.get(
"/ocs/v2.php/cloud/user",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
)
if response.status_code in (401, 403):
logger.info(
f"App password for {user_id} is invalid "
f"(HTTP {response.status_code}), removing"
)
await self.delete_app_password(user_id)
removed.append(user_id)
else:
logger.debug(
f"App password for {user_id} validated "
f"(HTTP {response.status_code})"
)
except Exception as e:
logger.warning(f"Could not validate app password for {user_id}: {e}")
async with anyio.create_task_group() as tg:
for user_id in user_ids:
tg.start_soon(_validate_user, user_id)
return removed
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
async def store_app_password_with_scopes(
self,
user_id: str,
app_password: str,
scopes: list[str] | None = None,
username: str | None = None,
) -> None:
"""Store encrypted app password with optional scopes and Nextcloud username.
Args:
user_id: MCP user ID (identity from OAuth token or session)
app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
# Defense-in-depth: validate scopes at storage layer
if scopes is not None:
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
ALL_SUPPORTED_SCOPES,
)
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at, scopes, username)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?,
?,
?
)
""",
(
user_id,
encrypted_password,
user_id,
now,
now,
scopes_json,
username,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(
f"Stored scoped app password for user {user_id} "
f"(scopes={'all' if scopes is None else len(scopes)}, "
f"username={username or 'N/A'})"
)
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
await self._audit_log(
event="store_app_password_with_scopes",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve app password with scopes and metadata.
Args:
user_id: MCP user ID
Returns:
Dict with keys: app_password, scopes, username, created_at, updated_at
or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_password, scopes, username, created_at, updated_at
FROM app_passwords WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password, scopes_json, username, created_at, updated_at = row
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"app_password": decrypted_password,
"scopes": scopes,
"username": username,
"created_at": created_at,
"updated_at": updated_at,
}
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
raise
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
Args:
user_id: MCP user ID
scopes: New scope list
Returns:
True if a row was updated, False if user not found
"""
if not self._initialized:
await self.initialize()
scopes_json = json.dumps(scopes)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
(scopes_json, now, user_id),
)
await db.commit()
updated = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "success")
if updated:
await self._audit_log(
event="update_app_password_scopes",
user_id=user_id,
auth_method="app_password",
)
return updated
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "error")
raise
# ── Login Flow v2: Session Tracking ──────────────────────────────────
async def store_login_flow_session(
self,
user_id: str,
poll_token: str,
poll_endpoint: str,
requested_scopes: list[str] | None = None,
expires_at: int | None = None,
) -> None:
"""Store a Login Flow v2 polling session.
Args:
user_id: MCP user ID
poll_token: Token for polling (will be encrypted)
poll_endpoint: URL to poll for completion
requested_scopes: Scopes requested in this flow
expires_at: Expiration timestamp (defaults to 20 minutes from now)
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
)
encrypted_token = self.cipher.encrypt(poll_token.encode())
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
now = int(time.time())
if expires_at is None:
expires_at = now + 1200 # 20 minutes default
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO login_flow_sessions
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
encrypted_token,
poll_endpoint,
scopes_json,
now,
expires_at,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored login flow session for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve a pending Login Flow v2 session.
Returns None if session doesn't exist or has expired.
Args:
user_id: MCP user ID
Returns:
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
or None if not found/expired
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at
FROM login_flow_sessions
WHERE user_id = ? AND expires_at > ?
""",
(user_id, now),
) as cursor:
row = await cursor.fetchone()
if not row:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
poll_token = self.cipher.decrypt(encrypted_token).decode()
requested_scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"poll_token": poll_token,
"poll_endpoint": poll_endpoint,
"requested_scopes": requested_scopes,
"created_at": created_at,
"expires_at": expires_at,
}
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(
f"Failed to retrieve login flow session for user {user_id}: {e}"
)
raise
async def delete_login_flow_session(self, user_id: str) -> bool:
"""Delete a Login Flow v2 session.
Args:
user_id: MCP user ID
Returns:
True if session was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted login flow session for user {user_id}")
await self._audit_log(
event="delete_login_flow_session",
user_id=user_id,
auth_method="login_flow",
)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def delete_expired_login_flow_sessions(self) -> int:
"""Delete all expired Login Flow v2 sessions.
Returns:
Number of sessions deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
(now,),
)
await db.commit()
count = cursor.rowcount
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if count > 0:
logger.info(f"Cleaned up {count} expired login flow sessions")
await self._audit_log(
event="delete_expired_login_flow_sessions",
user_id="system",
auth_method="login_flow",
)
return count
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
_shared_instance: RefreshTokenStorage | None = None
_shared_lock: anyio.Lock = anyio.Lock()
async def get_shared_storage() -> RefreshTokenStorage:
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
All modules that need storage should use this function instead of
creating their own lazy singletons. The lock ensures thread-safe
initialization on concurrent first-access.
"""
global _shared_instance
async with _shared_lock:
if _shared_instance is None:
_shared_instance = RefreshTokenStorage.from_env()
await _shared_instance.initialize()
return _shared_instance
async def generate_encryption_key() -> str:
"""
@@ -0,0 +1,524 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0082c9">
<title>{% block title %}Nextcloud MCP Server{% endblock %}</title>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 512 512'><rect width='512' height='512' rx='80' ry='80' fill='%230082C9'/><path d='M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z' fill='%23fff'/></svg>">
<!-- Open Sans font -->
<style>
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: normal;
src: local('Open Sans'), local('OpenSans');
}
@font-face {
font-family: 'Open Sans';
font-style: normal;
font-weight: bold;
src: local('Open Sans Semibold'), local('OpenSans-Semibold');
}
</style>
{% block extra_head %}{% endblock %}
<style>
/* Nextcloud App Design System */
/* CSS Variables */
:root {
/* Primary Colors */
--color-primary: #00679e;
--color-primary-element: #00679e;
--color-primary-light: #e5eff5;
--color-primary-element-light: #e5eff5;
/* Background Colors */
--color-main-background: #ffffff;
--color-background-dark: #ededed;
--color-background-hover: #f5f5f5;
/* Text Colors */
--color-main-text: #222222;
--color-text-maxcontrast: #6b6b6b;
--color-text-light: #767676;
/* Border Colors */
--color-border: #ededed;
--color-border-dark: #dbdbdb;
/* Borders & Radius */
--border-radius: 3px;
--border-radius-large: 10px;
--border-radius-pill: 100px;
/* Spacing */
--default-grid-baseline: 4px;
--default-clickable-area: 44px;
}
/* SVG Icon Styles */
.nav-icon {
width: 20px;
height: 20px;
display: inline-block;
fill: var(--color-main-text);
opacity: 0.7;
}
.app-navigation-entry.active .nav-icon {
fill: var(--color-primary-element);
opacity: 1;
}
/* General */
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--color-main-text);
background: var(--color-main-background);
margin: 0;
padding: 0;
}
h1, h2, h3 {
font-weight: 300;
line-height: 1.2;
}
h1 {
font-size: 32px;
margin: 0 0 20px 0;
color: var(--color-main-text);
}
h2 {
font-size: 20px;
margin: 20px 0 12px 0;
color: var(--color-main-text);
border-bottom: 1px solid var(--color-border);
padding-bottom: 8px;
}
h3 {
font-size: 16px;
margin: 16px 0 8px 0;
color: var(--color-main-text);
font-weight: 500;
}
img {
max-width: 100%;
}
/* App Header (simplified, no full menu) */
.app-header {
height: 50px;
background: var(--color-primary-element);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
padding: 0 20px;
}
.app-header__brand {
color: white;
font-size: 18px;
font-weight: 600;
text-decoration: none;
display: flex;
align-items: center;
gap: 12px;
}
.app-header__brand:hover {
opacity: 0.9;
}
.app-header__logo {
height: 32px;
width: 32px;
fill: white;
}
/* App Layout */
.app-content-wrapper {
display: flex;
height: calc(100vh - 50px);
overflow: hidden;
}
/* Side Navigation */
#app-navigation {
width: 250px;
background: var(--color-main-background);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
flex-shrink: 0;
transition: margin-left 0.3s ease;
}
#app-navigation.app-navigation--closed {
margin-left: -250px;
}
.app-navigation__content {
flex: 1;
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
}
.app-navigation-list {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
}
.app-navigation-entry {
position: relative;
margin-bottom: 2px;
}
.app-navigation-entry__wrapper {
display: flex;
align-items: center;
position: relative;
}
.app-navigation-entry-link {
display: flex;
align-items: center;
padding: 0 8px;
min-height: var(--default-clickable-area);
border-radius: var(--border-radius);
transition: background-color 100ms ease-in-out;
text-decoration: none;
color: var(--color-main-text);
flex: 1;
font-size: 14px;
}
.app-navigation-entry-link:hover {
background-color: var(--color-background-hover);
}
.app-navigation-entry.active .app-navigation-entry-link {
background-color: var(--color-primary-element-light);
font-weight: 500;
}
.app-navigation-entry-icon {
width: var(--default-clickable-area);
height: var(--default-clickable-area);
display: flex;
align-items: center;
justify-content: center;
margin-right: 0;
}
.app-navigation-entry__name {
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-navigation-entry__counter {
margin-left: auto;
padding: 2px 6px;
border-radius: var(--border-radius-pill);
background-color: var(--color-background-dark);
font-size: 11px;
color: var(--color-text-maxcontrast);
min-width: 20px;
text-align: center;
}
.app-navigation__settings {
list-style: none;
padding: 8px 0 0 0;
margin: 8px 0 0 0;
border-top: 1px solid var(--color-border);
flex-shrink: 0;
}
.app-navigation-toggle {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
top: 60px;
left: 10px;
z-index: 110;
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius);
padding: 8px 12px;
cursor: pointer;
box-shadow: 0 0 5px rgba(0,0,0,0.1);
transition: left 0.3s ease;
}
.app-navigation-toggle:hover {
background: var(--color-background-hover);
}
#app-navigation:not(.app-navigation--closed) ~ * .app-navigation-toggle {
left: 260px;
}
/* Main Content Area */
#app-content {
flex: 1;
overflow-y: auto;
background: var(--color-main-background);
}
.page-content {
max-width: 1000px;
margin: 0 auto;
padding: 24px;
}
.content-section {
background: var(--color-main-background);
border-radius: 0;
padding: 0;
box-shadow: none;
}
.content-section h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 24px;
}
.content-section h2 {
font-size: 18px;
font-weight: 500;
margin: 24px 0 12px 0;
border-bottom: none;
padding-bottom: 0;
}
.content-section h3 {
font-size: 16px;
font-weight: 500;
}
/* Responsive */
@media (max-width: 768px) {
#app-navigation {
position: fixed;
height: calc(100vh - 50px);
z-index: 105;
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
}
.page-content {
padding: 16px;
}
}
/* Footer */
footer.page-footer {
background-color: #0F0833;
color: #ffffff;
padding: 40px 0;
margin-top: 60px;
}
footer.page-footer .bootstrap-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 20px;
}
footer.page-footer h1 {
font-size: 15px;
font-weight: bold;
line-height: 1.8;
color: #ffffff;
margin-top: 20px;
}
footer.page-footer ul {
list-style-type: none;
padding-left: 0;
}
footer.page-footer li {
font-size: 13px;
line-height: 1.8;
color: #ffffff;
margin-top: 0;
}
footer.page-footer li a {
color: #ffffff;
text-decoration: none;
display: block;
padding: 4px 0;
}
footer.page-footer li a:hover {
text-decoration: underline;
}
footer.page-footer p {
font-size: 15px;
line-height: 1.8;
color: #ffffff;
}
footer.page-footer p.copyright {
color: rgba(255, 255, 255, 0.5);
font-size: 13px;
text-align: center;
margin-top: 30px;
}
/* Buttons */
.btn {
border-radius: 50px;
padding: 10px 20px;
text-decoration: none;
display: inline-block;
cursor: pointer;
border: none;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background: #0082C9;
border: 1px solid #0062C9;
color: #fff;
}
.btn-primary:hover {
background: #006ba3;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
td {
padding: 12px 8px;
border-bottom: 1px solid var(--color-border);
font-size: 14px;
}
td:first-child {
width: 180px;
color: var(--color-text-maxcontrast);
font-weight: 500;
}
code {
background-color: var(--color-background-dark);
padding: 2px 6px;
border-radius: var(--border-radius);
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
font-size: 90%;
color: var(--color-main-text);
}
/* Badges */
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.badge-oauth {
background-color: #4caf50;
color: white;
}
.badge-basic {
background-color: #2196f3;
color: white;
}
/* Messages */
.warning {
background-color: #fff3cd;
border-left: 4px solid #ffc107;
padding: 15px;
margin: 15px 0;
color: #856404;
}
.info-message {
background-color: #e3f2fd;
border-left: 4px solid #2196f3;
padding: 15px;
margin: 15px 0;
color: #1565c0;
}
.error {
background-color: #ffebee;
border-left: 4px solid #d32f2f;
padding: 15px;
margin: 15px 0;
color: #c62828;
}
.success {
background-color: #e8f5e9;
border: 2px solid #4caf50;
padding: 30px;
border-radius: 8px;
text-align: center;
}
.success h1 {
color: #4caf50;
}
{% block extra_styles %}{% endblock %}
</style>
</head>
<body>
<!-- App Header -->
<header class="app-header">
<a href="/app" class="app-header__brand">
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
</svg>
<span>Nextcloud MCP Server</span>
</a>
</header>
<!-- App Content Wrapper (Sidebar + Main Content) -->
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

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