Compare commits
534 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e8a467f2d0 | |||
| 989d3f2857 | |||
| 92d5cd4e26 | |||
| 5823286907 | |||
| 7fb6613bc2 | |||
| cd6f0ffa63 | |||
| 5d98858bb6 | |||
| af7c752cc1 | |||
| 2526390ce8 | |||
| 0b5571f3d7 | |||
| 059f37d093 | |||
| 28ad0aefbf | |||
| 6ce9599757 | |||
| 1cdf148899 | |||
| 8b16d79d6c | |||
| 45cc4c68fc | |||
| b4c98b25ee | |||
| 1176479ec1 | |||
| 0f8b1c6325 | |||
| fdb7b87baf | |||
| 47fb562326 | |||
| 1fae6920be | |||
| 184415eca1 | |||
| 658fd7e138 | |||
| a5d2025797 | |||
| f43343356e | |||
| 0a53aa5fcd | |||
| abd43f8028 | |||
| e7157ab256 | |||
| 08aaa85ab3 | |||
| ecab777efa | |||
| c960560716 | |||
| 023927afff | |||
| 3a87b33288 | |||
| c8ebd9c089 | |||
| 5947fff13f | |||
| a9e5c687b8 | |||
| 9d1a84af5a | |||
| d09ebf20cc | |||
| 0d14c75eb1 | |||
| ba597634bd | |||
| 1a6ce0fa7d | |||
| 3df0b06cd1 | |||
| 0b8afec494 | |||
| bd69e68dd5 | |||
| 148573e28b | |||
| 5d81d60262 | |||
| b86e798ba8 | |||
| a7d623733b | |||
| 3311b20ef6 | |||
| 28c7f1cdbd | |||
| 2713f74be6 | |||
| e3c5a87b22 | |||
| 53cf223a56 | |||
| 6bfde0de1f | |||
| 8cf3264914 | |||
| ed2f400ed8 | |||
| 6ba598afd1 | |||
| d0bfecea97 | |||
| bf0a4ac5d3 | |||
| 3da6feba41 | |||
| 1224090469 | |||
| aa624401c3 | |||
| 61e867397c | |||
| db1e0606ad | |||
| 33cf0fee9b | |||
| b2fd4da9fe | |||
| 16cd2e27cb | |||
| e28af5453b | |||
| 87ec3c4f5b | |||
| 989749530c | |||
| 2d46959d01 | |||
| 59fdcd123a | |||
| b79c54cc6a | |||
| fe3fbe95a1 | |||
| 8fe7d81e57 | |||
| 8b5c2395b5 | |||
| 5796e2ba54 | |||
| 37141ea79f | |||
| 68126f6fe3 | |||
| 78b934ffa6 | |||
| 01a9ad5278 | |||
| b67a566902 | |||
| c9e8a56355 | |||
| 785ba5bf09 | |||
| 159ffb6110 | |||
| 70139c4782 | |||
| a922187489 | |||
| 1ba6a142f5 | |||
| 79478f2483 | |||
| 4721a5da52 | |||
| be2b683604 | |||
| 9fd3d92a0f | |||
| ceebda071f | |||
| 26fc48dc46 | |||
| 3edc226d17 | |||
| 7384b47795 | |||
| b62d275dc9 | |||
| a0fa0230ab | |||
| 7314097483 | |||
| 3d070f74c5 | |||
| 80366a4e1e | |||
| 91941a9ece | |||
| 8fd6f4158f | |||
| b8e6539b6f | |||
| fe53e93fe9 | |||
| 71d4c44b05 | |||
| 8261048741 | |||
| 6443aca743 | |||
| a1b5e676e9 | |||
| 1d9168f614 | |||
| 9229440a58 | |||
| e507f29e83 | |||
| 5ac6d8d396 | |||
| ab71003c5d | |||
| 726b71eea1 | |||
| 3e50924169 | |||
| b2773317ef | |||
| dce3ca9a70 | |||
| 18e5baf2a5 | |||
| 24bc29ea64 | |||
| 44e7e2e09b | |||
| bcc0bfee8d | |||
| 0f31d16158 | |||
| 7c0b84d398 | |||
| f51b27ba19 | |||
| 010eb40d5c | |||
| 960d060d27 | |||
| 76e6c12b56 | |||
| 76e305006c | |||
| 8887aa241a | |||
| 10d44edf4c | |||
| f5b4658d5a | |||
| 39d160ce48 | |||
| a11ae9c027 | |||
| 81efa6e263 | |||
| aaddd0d5a9 | |||
| a5eb16c1ac | |||
| 6f7a06e558 | |||
| 0e4c8453bf | |||
| 2dba3179bd | |||
| 5f0e208193 | |||
| 3779ec3e17 | |||
| f2df19c39b | |||
| 5562c943c0 | |||
| 12c02ffe00 | |||
| d2e1391f37 | |||
| ac91aacaf5 | |||
| ad9fcddca1 | |||
| 0e57cf6389 | |||
| b9a185ba1c | |||
| 9aa6b44397 | |||
| 1aa21663b7 | |||
| d145e4d5de | |||
| cf4ed4a641 | |||
| 8d84d95ada | |||
| 992d380585 | |||
| e51fc48206 | |||
| 2657071404 | |||
| 75325f16fc | |||
| 1d4ff3fbe0 | |||
| 778b08cc84 | |||
| 8cab588f21 | |||
| 8233cc9dcf | |||
| 0d259d2dfd | |||
| dfc676a847 | |||
| cf627a9c48 | |||
| 037e88e416 | |||
| dae2f276ae | |||
| d94610d0ec | |||
| af0b9c1f93 | |||
| 2d7360ebd7 | |||
| 56542802bc | |||
| c03dbd1b55 | |||
| 99925d9f22 | |||
| 0dfaf954d7 | |||
| b3fe7099cb | |||
| 7152537fd4 | |||
| 9d31925f27 | |||
| 3a322c34bc | |||
| b1bd025aac | |||
| 8a1c604d78 | |||
| 3616dee54c | |||
| dbb36a7b63 | |||
| f1797b2f8e | |||
| 1d5d4f86d7 | |||
| 44030805f1 | |||
| afd7e69f76 | |||
| 31be72ae24 | |||
| 6bd05a81bf | |||
| a4e3f0b354 | |||
| 0f23964752 | |||
| 66ccacdee1 | |||
| 1a4486a388 | |||
| 91d06acfb4 | |||
| 90874ca7cd | |||
| da8fed3382 | |||
| 8963e65f1b | |||
| 75c3868e74 | |||
| 1707b2e6e1 | |||
| df3cce4370 | |||
| 1c5e21843e | |||
| 520ef113ba | |||
| 3be229a487 | |||
| 6da69b0336 | |||
| 427e501691 | |||
| 9c275d1a3f | |||
| af43630ca7 | |||
| 49c5439686 | |||
| c5eec64716 | |||
| 3948f6a019 | |||
| 08d37a6597 | |||
| 4712235390 | |||
| d0f18b36e8 | |||
| aca0d236b4 | |||
| 7ab0dcd3d8 | |||
| eafef986f2 | |||
| 8126beb16e | |||
| bce6686494 | |||
| dfc75a8619 | |||
| 254cb6cf06 | |||
| 940e7d3e4e | |||
| ac985b265e | |||
| e7f452342e | |||
| e4b5617a55 | |||
| 291a13c064 | |||
| 0e9fee5616 | |||
| 093f1d7302 | |||
| 9da5f95bcb | |||
| 1d4aede0f9 | |||
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 119a422a35 | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c | |||
| 96598510ee | |||
| 02cb1f5491 | |||
| 3856698d0a | |||
| 3a05f0cfb3 | |||
| fe5e7f7a60 | |||
| b7257f4e59 | |||
| 7cc852f0da | |||
| 525258be67 | |||
| 49bd3100ad | |||
| 6693bab9f9 | |||
| 8e0d64f7d3 | |||
| c97ffe8e47 | |||
| d0115170c2 | |||
| 9ec00d4de5 | |||
| 9527427782 | |||
| fbfc8b8a05 | |||
| e85000424d | |||
| 58ac60be12 | |||
| 77ef928060 | |||
| 00afac8e46 | |||
| d22cebc69a | |||
| 151d595360 | |||
| 7e02a58546 | |||
| 25dee9bfaf | |||
| f898d61077 | |||
| 0aaa3fc912 | |||
| 77fabccdb7 | |||
| 2648ef2567 | |||
| 405a57649a | |||
| 252df1d398 | |||
| 0ad81a1fd8 | |||
| dce864e947 | |||
| b9f1040dd5 | |||
| c7882adb24 | |||
| 9491d698e8 | |||
| 5b71ac3251 | |||
| 815a09be34 | |||
| c46f9eb212 | |||
| 28219e00e7 | |||
| daaf460b0c | |||
| 04f05f725c | |||
| b499aa2abe | |||
| 72df7dd1eb | |||
| 2e7774654b | |||
| 61ce873411 | |||
| 0af9657fea | |||
| 8507e480d6 | |||
| 905d18baf7 | |||
| b5e5d86790 | |||
| c35e94b0bc | |||
| c09ebe99cc | |||
| d5544a7731 | |||
| bc62f2a066 | |||
| 38adb96be4 | |||
| c76dd21eeb | |||
| c5bf4cda8a | |||
| 0b6a6b0842 | |||
| 9c4c4d4563 | |||
| 2d74b1a1fb | |||
| 26ba237142 | |||
| 7b75304c9f | |||
| 9004e14022 | |||
| e7a3dd698a | |||
| c12007c342 | |||
| f37cf8a159 | |||
| 07f2952599 | |||
| 6cf916876a | |||
| 27b11eabf9 | |||
| da31dec33e | |||
| a61bcccdac | |||
| 774de68966 | |||
| 44b77875f7 | |||
| 5469cf05f0 | |||
| 6832ae1198 | |||
| 619faaf1df | |||
| 34387ff202 | |||
| 76d3174264 | |||
| 723337754f | |||
| 2d79fc6c3d | |||
| 80972f5d37 | |||
| f0ade4ad28 | |||
| 737f10f190 | |||
| 813e9a60cb | |||
| 5c25b87cbe | |||
| e48c5fa9a2 | |||
| 303efeddf7 | |||
| c9bf3d0b52 | |||
| 9f64609722 | |||
| 89becbb92b | |||
| fef13a6d3d | |||
| c4973290a6 | |||
| c018268681 | |||
| 79cfb65590 | |||
| 9750845092 | |||
| 7e8171132b | |||
| 910792178b | |||
| 80c5647f3e | |||
| a306549907 | |||
| 295e3d2783 | |||
| 47dcdf8b61 | |||
| 8c6ae9ff33 | |||
| 04fee00a0b | |||
| 9e1fc1ebeb | |||
| 6eceefdacc | |||
| b147814cc4 | |||
| 5a58c81626 | |||
| 1cc460b0d8 | |||
| 104a2ec9e3 | |||
| e87ae56041 | |||
| c95459234b | |||
| f16f852b23 | |||
| b93d7bd19b | |||
| 9a69cef815 | |||
| 2424afbdda | |||
| 0a987467b5 | |||
| ab6f7ca0b2 | |||
| 42fa33d0bf | |||
| 006a3d95d6 | |||
| 1835965f44 | |||
| cb4e8acd9f | |||
| 02418a9531 | |||
| f89151d099 | |||
| dc86386bf8 | |||
| 929c40709a | |||
| a60560256d | |||
| aa583ab973 | |||
| 4103924b83 | |||
| c192bd2ec9 | |||
| 2005d2841f | |||
| c6295b48a5 | |||
| 7444c73a5a | |||
| cf0781d2fe | |||
| 6681cd0603 | |||
| c305a549d3 | |||
| 1f1dd94598 | |||
| 01ad2b3d21 | |||
| e4cddef343 | |||
| f15baefe7e | |||
| 585ed46f2d | |||
| dbbbab5320 | |||
| e5844b3da8 | |||
| fdbf88831a | |||
| 6affad1c8b | |||
| 370c3ff444 | |||
| e486e92f91 | |||
| 7465e962d4 | |||
| 99fe764c5e | |||
| 46f896b526 | |||
| a61572e8ef | |||
| a474996df4 | |||
| 5d6dd5ad38 | |||
| 21e4d3effd | |||
| 817df43af1 | |||
| 906b9d892c | |||
| 534723c9f6 | |||
| 1d5832ed3a | |||
| 844bd589e0 | |||
| 127af15623 | |||
| ff5fc5d5b2 | |||
| 158865d99f | |||
| 94674eca27 | |||
| a8b5d6e701 | |||
| e0675b2127 | |||
| 86582bdb8f | |||
| dc8009a785 | |||
| b5e658e1ff | |||
| 6a19c2d136 | |||
| 99e359ffbf | |||
| f16f4e8cb5 | |||
| 8597f2a272 | |||
| 11f67e2bc4 | |||
| 2e49a16e49 | |||
| 713fddeaa5 | |||
| 0dfefb0516 | |||
| 63d2aeaa43 | |||
| 07f0a7c0dc | |||
| 84bde6d5ed | |||
| 9695f8a6d7 | |||
| a2c410e8d2 | |||
| 271b5f6155 | |||
| ba4f7c1429 | |||
| c763e96596 | |||
| 23e9cbaec5 | |||
| ddd5defa40 | |||
| 723dcc524d | |||
| 46eba0a693 | |||
| b61980a623 | |||
| 65cc894e21 | |||
| 700996e100 | |||
| 546f0c0674 | |||
| e625eab689 | |||
| a26a470af6 | |||
| 71ace47197 | |||
| 30d3d9f0cf | |||
| ef9e1b3ff8 | |||
| dd23191987 | |||
| 55312b1032 | |||
| 48a4182ef9 | |||
| 13dd709fc2 | |||
| dd66d4bbbc | |||
| 663e66af81 | |||
| 9c17bbfe9c | |||
| 052db2cf56 | |||
| 056414752e | |||
| b841407f07 | |||
| 555c26526e | |||
| 5b9e91bdee | |||
| 5d49b5903a | |||
| 9a6a253858 | |||
| 0a23e484e9 | |||
| 779d474aaa | |||
| 894bf5f916 | |||
| 804480836e | |||
| 5e2ef5f35b | |||
| a51376fd5a | |||
| 10a0969138 | |||
| 5e76ddc60d | |||
| 9ea1902e2b | |||
| dd42849d70 | |||
| 4248b67b2e | |||
| 755e398a1f | |||
| 036c6352fb | |||
| d7c99fcc69 | |||
| 47095fabcd | |||
| 85b7b935b3 | |||
| 6e2be579e0 | |||
| 8ba3ae73ab | |||
| dbf3d5ec10 | |||
| 5b9e76ddb4 | |||
| 541f7a6abd | |||
| 28cfee4bab | |||
| 358d962822 | |||
| ea96a58678 | |||
| 9b5c6779e9 | |||
| 04140d671e | |||
| ff8828e972 | |||
| 43c7421d28 | |||
| e49dc2bfc4 | |||
| 4a5766b84e | |||
| 65c3f099fa | |||
| b293258210 | |||
| 8f83034c79 | |||
| d195fc43d2 | |||
| 1a5bb10cd0 | |||
| a987643f8e | |||
| 34273ec01e | |||
| fd7f33943d | |||
| ecaa1f8f01 | |||
| d29922039b | |||
| 12541e57a6 | |||
| b99418451c |
@@ -1,89 +0,0 @@
|
||||
name: Build and Publish Astrolabe App Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'astrolabe-v*'
|
||||
|
||||
env:
|
||||
APP_NAME: astrolabe
|
||||
APP_DIR: third_party/astrolabe
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: tag
|
||||
run: |
|
||||
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate version in info.xml matches tag
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
|
||||
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
|
||||
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validated: $INFO_VERSION"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
coverage: none
|
||||
|
||||
- name: Checkout Nextcloud server (for signing)
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: nextcloud/server
|
||||
ref: stable30
|
||||
path: server
|
||||
|
||||
- name: Install dependencies and build
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup signing certificate
|
||||
run: |
|
||||
mkdir -p $HOME/.nextcloud/certificates
|
||||
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
|
||||
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
|
||||
|
||||
- name: Build app store package
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: make appstore server_dir=${{ github.workspace }}/server
|
||||
|
||||
- name: Create GitHub release and attach tarball
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
|
||||
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
|
||||
- name: Upload to Nextcloud App Store
|
||||
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
@@ -1,275 +0,0 @@
|
||||
# Consolidated CI workflow for Astroglobe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astroglobe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astroglobe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astroglobe/**'
|
||||
- '.github/workflows/astroglobe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
php: ${{ steps.changes.outputs.php }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- 'third_party/astroglobe/src/**'
|
||||
- 'third_party/astroglobe/package.json'
|
||||
- 'third_party/astroglobe/package-lock.json'
|
||||
- 'third_party/astroglobe/vite.config.js'
|
||||
- 'third_party/astroglobe/**/*.js'
|
||||
- 'third_party/astroglobe/**/*.ts'
|
||||
- 'third_party/astroglobe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astroglobe/lib/**'
|
||||
- 'third_party/astroglobe/appinfo/**'
|
||||
- 'third_party/astroglobe/composer.json'
|
||||
- 'third_party/astroglobe/psalm.xml'
|
||||
|
||||
# Node.js build and lint
|
||||
node-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Node.js build
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies & build
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Check webpack build changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets' && exit 1)"
|
||||
|
||||
# ESLint
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: ESLint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Stylelint
|
||||
stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Stylelint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run stylelint
|
||||
|
||||
# PHP Code Style
|
||||
php-cs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: PHP CS Fixer
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
|
||||
# Psalm Static Analysis
|
||||
psalm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: Psalm
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Get OCP version matrix
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for static analysis
|
||||
run: |
|
||||
# Get first OCP version from matrix
|
||||
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
|
||||
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run Psalm
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
|
||||
if: always()
|
||||
name: astroglobe-ci-summary
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: |
|
||||
if ${{ needs.changes.outputs.frontend != 'false' && (needs.node-build.result != 'success' || needs.eslint.result != 'success' || needs.stylelint.result != 'success') }}; then
|
||||
echo "Frontend checks failed"
|
||||
exit 1
|
||||
fi
|
||||
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
|
||||
echo "PHP checks failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed"
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# Bump MCP server (default - all commits except helm/astrolabe scopes)
|
||||
# Bump MCP server (default - all commits except helm scope)
|
||||
echo "Checking MCP server for version bump..."
|
||||
|
||||
# Get the most recent MCP tag
|
||||
@@ -83,33 +83,36 @@ jobs:
|
||||
commit_range="${last_mcp_tag}..HEAD"
|
||||
fi
|
||||
|
||||
# Count conventional commits that are NOT scoped to helm or astrolabe
|
||||
# Count conventional commits that are NOT scoped to helm
|
||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
||||
{ grep -v "(helm)" || true; } | wc -l)
|
||||
|
||||
MCP_BUMPED=false
|
||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||
echo "Bumping MCP server version..."
|
||||
./scripts/bump-mcp.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||
MCP_BUMPED=true
|
||||
else
|
||||
echo "No commits found for MCP server since $last_mcp_tag"
|
||||
fi
|
||||
|
||||
# Bump Helm chart (scope: helm)
|
||||
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
|
||||
echo "Checking Helm chart for version bump..."
|
||||
HELM_HAS_COMMITS=false
|
||||
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||
echo "Bumping Helm chart version..."
|
||||
./scripts/bump-helm.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
HELM_HAS_COMMITS=true
|
||||
fi
|
||||
|
||||
# Bump Astrolabe (scope: astrolabe)
|
||||
echo "Checking Astrolabe for version bump..."
|
||||
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
|
||||
echo "Bumping Astrolabe version..."
|
||||
./scripts/bump-astrolabe.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
|
||||
if [ "$HELM_HAS_COMMITS" = true ]; then
|
||||
echo "Bumping Helm chart version (helm-scoped commits)..."
|
||||
./scripts/bump-helm.sh
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
elif [ "$MCP_BUMPED" = true ]; then
|
||||
echo "Bumping Helm chart version (appVersion changed)..."
|
||||
./scripts/bump-helm.sh --increment PATCH
|
||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||
fi
|
||||
|
||||
# Output summary
|
||||
@@ -147,10 +150,6 @@ jobs:
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
astrolabe)
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -27,15 +27,16 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||
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 }}
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -34,18 +34,18 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
+154
-46
@@ -1,4 +1,4 @@
|
||||
name: Docker Compose Action
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -9,97 +9,205 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
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@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
- 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- 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@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
- name: Set up PHP 8.4
|
||||
if: matrix.mode != 'single-user'
|
||||
uses: shivammathur/setup-php@accd6127cb78bee3e8082180cb391013d204ef9f # 2.37.0
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
|
||||
- name: Install OIDC app composer dependencies
|
||||
run: |
|
||||
cd third_party/oidc
|
||||
composer install --no-dev
|
||||
# OIDC app installed from app store (dev mount removed from docker-compose.yml)
|
||||
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
|
||||
###### Required to build Astrolabe App ######
|
||||
|
||||
- name: Set up Node.js for Astrolabe
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||
- name: Set up Node.js
|
||||
if: matrix.mode != 'single-user'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: '20'
|
||||
node-version: 24
|
||||
|
||||
- name: Build Astrolabe app
|
||||
if: matrix.mode != 'single-user'
|
||||
run: |
|
||||
cd third_party/astrolabe
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
###### Required to build Astrolabe App ######
|
||||
|
||||
|
||||
# Start services with the appropriate profile
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
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@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # v7.3.1
|
||||
|
||||
- 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 unit -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
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
[submodule "third_party/notes"]
|
||||
path = third_party/notes
|
||||
url = https://github.com/cbcoutinho/notes
|
||||
[submodule "third_party/astrolabe"]
|
||||
path = third_party/astrolabe
|
||||
url = https://github.com/cbcoutinho/astrolabe
|
||||
|
||||
+294
@@ -5,6 +5,300 @@ 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
|
||||
|
||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
||||
|
||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
||||
|
||||
### Quick Query Script (Recommended for Agents)
|
||||
|
||||
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
|
||||
|
||||
```bash
|
||||
# Basic query
|
||||
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
|
||||
|
||||
# Vertical output (one column per line) - useful for wide tables
|
||||
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
|
||||
|
||||
# With different credentials
|
||||
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
|
||||
```
|
||||
|
||||
### Direct Docker Access
|
||||
|
||||
For interactive sessions or complex operations:
|
||||
|
||||
```bash
|
||||
# Connect to database
|
||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
||||
@@ -264,6 +283,40 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
|
||||
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
|
||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
||||
|
||||
### SQLite Databases (MCP Services)
|
||||
|
||||
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
|
||||
|
||||
```bash
|
||||
# List tables
|
||||
./scripts/sqlitequery.py ".tables"
|
||||
|
||||
# Query specific service
|
||||
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
|
||||
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
|
||||
|
||||
# With column headers
|
||||
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
|
||||
|
||||
# JSON output
|
||||
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
|
||||
|
||||
# View schema
|
||||
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
|
||||
```
|
||||
|
||||
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
|
||||
|
||||
**SQLite Tables**:
|
||||
- `refresh_tokens` - OAuth refresh tokens with user profiles
|
||||
- `audit_logs` - Security audit trail
|
||||
- `oauth_clients` - DCR OAuth client credentials
|
||||
- `oauth_sessions` - OAuth flow session state
|
||||
- `registered_webhooks` - Webhook registrations
|
||||
- `app_passwords` - Multi-user BasicAuth passwords
|
||||
- `alembic_version` - Migration tracking
|
||||
|
||||
## Architecture Quick Reference
|
||||
|
||||
**For detailed architecture, see:**
|
||||
|
||||
+3
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
## Version Management
|
||||
|
||||
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||
This monorepo uses commitizen for version management with **independent versioning** for two components:
|
||||
|
||||
### Components
|
||||
|
||||
@@ -10,7 +10,8 @@ This monorepo uses commitizen for version management with **independent versioni
|
||||
|-----------|-------|--------------|-------------|
|
||||
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||
|
||||
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
|
||||
# Helm chart changes
|
||||
feat(helm): add resource limits
|
||||
docs(helm): update values documentation
|
||||
|
||||
# Astrolabe app changes
|
||||
feat(astrolabe): add dark mode toggle
|
||||
fix(astrolabe): resolve search UI bug
|
||||
```
|
||||
|
||||
**Unscoped commits** default to the MCP server:
|
||||
@@ -40,7 +37,6 @@ feat: add new feature # → MCP server (v0.54.0)
|
||||
#### 1. Make Changes with Scoped Commits
|
||||
|
||||
```bash
|
||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||
git commit -m "feat(helm): add ingress annotations"
|
||||
git commit -m "feat(mcp): add calendar sync"
|
||||
```
|
||||
@@ -58,10 +54,6 @@ git commit -m "feat(mcp): add calendar sync"
|
||||
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||
# → Updates: Chart.yaml:version
|
||||
|
||||
# Bump Astrolabe (reads commits with scope=astrolabe)
|
||||
./scripts/bump-astrolabe.sh
|
||||
# → Creates tag: astrolabe-v0.2.0
|
||||
# → Updates: info.xml, package.json
|
||||
```
|
||||
|
||||
#### 3. Push Tags
|
||||
@@ -76,7 +68,6 @@ Each component maintains its own `CHANGELOG.md`:
|
||||
|
||||
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||
|
||||
### Manual Version Bumps
|
||||
|
||||
@@ -101,7 +92,6 @@ uv run cz --config .cz.toml bump --increment MINOR
|
||||
|
||||
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||
|
||||
### Chart.yaml Version vs appVersion
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -55,6 +55,15 @@ http://127.0.0.1:8000/sse
|
||||
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:**
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||
@@ -99,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.
|
||||
|
||||
@@ -127,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
|
||||
|
||||
@@ -4,8 +4,8 @@ set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
|
||||
# Set overwrite settings for URL generation (needed for OIDC discovery to return correct URLs)
|
||||
# These ensure that URLs generated by Nextcloud include the correct host:port
|
||||
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
|
||||
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
|
||||
# Set overwrite.cli.url to the external URL for OIDC discovery
|
||||
# This ensures OAuth flows redirect to the correct external URL
|
||||
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
|
||||
# which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80)
|
||||
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing Astrolabe app for testing..."
|
||||
echo "Installing Astrolabe app..."
|
||||
|
||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||
if [ -d /opt/apps/astrolabe ]; then
|
||||
@@ -30,7 +30,7 @@ else
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
fi
|
||||
|
||||
echo "✓ Astrolabe app installed successfully"
|
||||
echo "Astrolabe app installed successfully"
|
||||
echo ""
|
||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||
echo " to support testing multiple MCP server deployments."
|
||||
|
||||
@@ -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"
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.54.0"
|
||||
version = "0.58.2"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
@@ -18,7 +18,8 @@ ignored_tag_formats = [
|
||||
]
|
||||
|
||||
# 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\\)(!)?:"
|
||||
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}}"
|
||||
|
||||
@@ -14,6 +14,453 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.58.2 (2026-03-14)
|
||||
|
||||
## nextcloud-mcp-server-0.58.1 (2026-03-03)
|
||||
|
||||
## nextcloud-mcp-server-0.58.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
|
||||
|
||||
## nextcloud-mcp-server-0.57.94 (2026-03-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
|
||||
|
||||
## nextcloud-mcp-server-0.57.93 (2026-03-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.92 (2026-03-02)
|
||||
|
||||
## nextcloud-mcp-server-0.57.91 (2026-03-02)
|
||||
|
||||
## nextcloud-mcp-server-0.57.90 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.89 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.88 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.87 (2026-03-01)
|
||||
|
||||
## nextcloud-mcp-server-0.57.86 (2026-02-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency icalendar to v7
|
||||
|
||||
## nextcloud-mcp-server-0.57.85 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.84 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.83 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.82 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.81 (2026-02-25)
|
||||
|
||||
## nextcloud-mcp-server-0.57.80 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.79 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.78 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.77 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.76 (2026-02-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.75 (2026-02-23)
|
||||
|
||||
## nextcloud-mcp-server-0.57.74 (2026-02-21)
|
||||
|
||||
## nextcloud-mcp-server-0.57.73 (2026-02-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #574 fourth review round
|
||||
- address PR #574 third review round
|
||||
- address PR #574 second review round
|
||||
- address PR #574 review comments
|
||||
- wrap raw list returns in response models to produce single TextContent block
|
||||
|
||||
## nextcloud-mcp-server-0.57.72 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #571 review comments
|
||||
- resolve stale credentials causing astrolabe background sync test failures
|
||||
|
||||
### Refactor
|
||||
|
||||
- enforce PLC0415 (import-outside-top-level) for source code
|
||||
|
||||
## nextcloud-mcp-server-0.57.69 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.67 (2026-02-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.66 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.65 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.64 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.63 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.62 (2026-02-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||
|
||||
## nextcloud-mcp-server-0.57.61 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.60 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.59 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.58 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.57 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.56 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.55 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.54 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.53 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.52 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.51 (2026-02-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- add self-signed SSL certificate support for Nextcloud connections
|
||||
|
||||
### Fix
|
||||
|
||||
- add type: ignore for caldav ssl_verify_cert parameter
|
||||
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||
|
||||
## nextcloud-mcp-server-0.57.50 (2026-02-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove stale astrolabe references from commitizen config
|
||||
- extract Astrolabe to separate repository
|
||||
|
||||
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.46 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.45 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.44 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.43 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.42 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## nextcloud-mcp-server-0.57.41 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||
|
||||
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.24 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.23 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.22 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.21 (2026-01-30)
|
||||
|
||||
## nextcloud-mcp-server-0.57.20 (2026-01-29)
|
||||
|
||||
## nextcloud-mcp-server-0.57.19 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.18 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.17 (2026-01-28)
|
||||
|
||||
## nextcloud-mcp-server-0.57.16 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
- **astrolabe**: add background token refresh job
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||
- **astrolabe**: add locking to prevent token refresh race condition
|
||||
- **astrolabe**: add issued_at to on-demand token refresh
|
||||
|
||||
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- **scripts**: add database query helpers for development
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
||||
- **astrolabe**: improve error messages for authorization issues
|
||||
- **astrolabe**: rename OAuthController and fix app password check
|
||||
- **tests**: improve Astrolabe integration test reliability
|
||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
||||
- **deps**: update dependency plotly.js-dist-min to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **api**: split management.py into domain-focused modules
|
||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
||||
|
||||
## nextcloud-mcp-server-0.57.14 (2026-01-26)
|
||||
|
||||
## nextcloud-mcp-server-0.57.13 (2026-01-24)
|
||||
|
||||
## nextcloud-mcp-server-0.57.12 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.11 (2026-01-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.10 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.9 (2026-01-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.8 (2026-01-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.7 (2026-01-17)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: improve token refresh error handling and validation
|
||||
- **astrolabe**: delete stale tokens when refresh fails
|
||||
- **astrolabe**: resolve CI failures for code quality checks
|
||||
- **astrolabe**: use internal URL for OAuth token refresh
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
||||
|
||||
## nextcloud-mcp-server-0.57.6 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.5 (2026-01-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.4 (2026-01-16)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
||||
|
||||
## nextcloud-mcp-server-0.57.3 (2026-01-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.2 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
||||
|
||||
## nextcloud-mcp-server-0.57.1 (2026-01-15)
|
||||
|
||||
### Fix
|
||||
|
||||
- **ci**: bump helm chart version when MCP appVersion changes
|
||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
||||
|
||||
## nextcloud-mcp-server-0.57.0 (2026-01-15)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add rate limiting and extract helpers for app password endpoints
|
||||
|
||||
### Fix
|
||||
|
||||
- Add missing annotations for deck remove/unassign operations
|
||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
||||
- **deck**: Always preserve fields in update_card for partial updates
|
||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
||||
|
||||
### Refactor
|
||||
|
||||
- Use get_settings() for vector sync enabled check
|
||||
- Extract storage helper and improve PHP error handling
|
||||
|
||||
## nextcloud-mcp-server-0.56.2 (2025-12-29)
|
||||
|
||||
### Fix
|
||||
|
||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
||||
|
||||
## nextcloud-mcp-server-0.56.1 (2025-12-26)
|
||||
|
||||
### Fix
|
||||
|
||||
- **mcp**: Move all imports to the top of modules
|
||||
|
||||
## nextcloud-mcp-server-0.56.0 (2025-12-26)
|
||||
|
||||
### Feat
|
||||
|
||||
- Remove URL rewriting in favor of proper nextcloud config
|
||||
- **helm**: migrate to new environment variable naming convention
|
||||
- Migrate to vue 3
|
||||
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
||||
|
||||
### Fix
|
||||
|
||||
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
||||
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
||||
- **auth**: Skip issuer validation for management API tokens
|
||||
- Use settings.enable_offline_access for env var consolidation
|
||||
- Add required config.py attributes
|
||||
- **docker**: remove overwritehost to fix container-to-container DCR
|
||||
- **deps**: update dependency @nextcloud/vue to v9
|
||||
- **deps**: update dependency vue to v3
|
||||
|
||||
### Refactor
|
||||
|
||||
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
||||
|
||||
## nextcloud-mcp-server-0.55.2 (2025-12-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: set OIDC client env vars when using existingSecret
|
||||
|
||||
## nextcloud-mcp-server-0.55.1 (2025-12-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: trigger chart release workflow on helm chart tags
|
||||
|
||||
## nextcloud-mcp-server-0.55.0 (2025-12-22)
|
||||
|
||||
### BREAKING CHANGE
|
||||
|
||||
- MCP server now bumps for ANY conventional commit except
|
||||
those explicitly scoped to helm or astrolabe.
|
||||
|
||||
### Feat
|
||||
|
||||
- **helm**: add support for multi-user BasicAuth mode
|
||||
- **config**: enable DCR for multi-user BasicAuth with offline access
|
||||
- **astrolabe**: implement app password provisioning for multi-user background sync
|
||||
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
||||
- **auth**: add multi-user BasicAuth pass-through mode
|
||||
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||
- **ci**: add --increment flag to bump scripts for manual version control
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: address PR #447 reviewer feedback
|
||||
- **helm**: include MCP server version bumps in changelog pattern
|
||||
- **config**: address reviewer feedback
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
- **astrolabe**: screenshots in info.xml
|
||||
- **astrolabe**: Update screenshots
|
||||
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
||||
- **astrolabe**: add contents:write permission to appstore workflow
|
||||
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
||||
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
||||
- **astrolabe**: info.xml
|
||||
- **ci**: push all tags explicitly in bump workflow
|
||||
- **ci**: make MCP server default bump target for all non-scoped commits
|
||||
- **ci**: restrict docker build to MCP server tags only
|
||||
- **ci**: correct appstore-push-action version to v1.0.4
|
||||
|
||||
### Refactor
|
||||
|
||||
- **config**: centralize configuration validation and simplify startup
|
||||
|
||||
## nextcloud-mcp-server-0.54.0 (2025-12-19)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.16.2
|
||||
version: 1.17.0
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.36.0
|
||||
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
|
||||
generated: "2025-12-14T11:07:07.024787592Z"
|
||||
version: 1.47.0
|
||||
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
|
||||
generated: "2026-03-02T11:15:27.688786078Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.54.0
|
||||
appVersion: "0.56.2"
|
||||
version: 0.58.2
|
||||
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.16.2"
|
||||
version: "1.17.0"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.36.0"
|
||||
version: "1.47.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
+82
-20
@@ -3,11 +3,13 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
|
||||
image: docker.io/library/mariadb:lts@sha256: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,18 +19,17 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
image: ${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
|
||||
@@ -36,6 +37,7 @@ services:
|
||||
# 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
|
||||
@@ -45,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
|
||||
@@ -52,14 +55,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -86,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
|
||||
|
||||
@@ -122,6 +125,8 @@ 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: .
|
||||
@@ -135,19 +140,31 @@ services:
|
||||
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)
|
||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||
# 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
|
||||
|
||||
# Vector sync disabled (stateless pass-through mode)
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- 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: .
|
||||
@@ -169,16 +186,16 @@ 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -196,9 +213,11 @@ services:
|
||||
volumes:
|
||||
- oauth-client-storage:/app/.oauth
|
||||
- oauth-tokens:/app/data
|
||||
profiles:
|
||||
- oauth
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -218,6 +237,8 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 30
|
||||
profiles:
|
||||
- keycloak
|
||||
|
||||
mcp-keycloak:
|
||||
build: .
|
||||
@@ -246,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
|
||||
|
||||
@@ -263,6 +284,45 @@ 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
|
||||
@@ -279,13 +339,13 @@ services:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- ENABLE_SEMANTIC_SEARCH=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
@@ -309,6 +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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
+238
-192
@@ -1,203 +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
|
||||
|
||||
# ============================================
|
||||
# MULTI-USER BASICAUTH MODE
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# OAUTH SINGLE-AUDIENCE MODE (Recommended)
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# OAUTH TOKEN EXCHANGE MODE (Advanced)
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# SMITHERY STATELESS MODE
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# OPTIONAL FEATURES (All Deployment Modes)
|
||||
# ============================================
|
||||
|
||||
# ===== 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)
|
||||
#VECTOR_SYNC_SCAN_INTERVAL=300
|
||||
#
|
||||
# Concurrent indexing workers (default: 3)
|
||||
#VECTOR_SYNC_PROCESSOR_WORKERS=3
|
||||
#
|
||||
# Max queued documents (default: 10000)
|
||||
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
|
||||
|
||||
# ===== 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
|
||||
|
||||
# ===== SSL/TLS =====
|
||||
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
|
||||
#
|
||||
# Disable TLS certificate verification (insecure, development only):
|
||||
#NEXTCLOUD_VERIFY_SSL=false
|
||||
#
|
||||
# Use a custom CA bundle (path to PEM file):
|
||||
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||
#
|
||||
# Docker example: mount the CA bundle as a volume
|
||||
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
|
||||
|
||||
# ===== SECURITY & ADVANCED =====
|
||||
# Cookie security (browser UI)
|
||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||
# Set explicitly for non-standard setups
|
||||
#COOKIE_SECURE=true
|
||||
|
||||
# ============================================
|
||||
# Document Processing Configuration
|
||||
# DEPRECATED VARIABLES (Backward Compatibility)
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# Unstructured.io Processor
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# Tesseract Processor (Local OCR)
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# Custom Processor (Your own API)
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# Semantic Search & Vector Sync Configuration
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Network mode: URL to Qdrant service
|
||||
#QDRANT_URL=http://qdrant:6333
|
||||
|
||||
# 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
|
||||
|
||||
# ============================================
|
||||
# Ollama Embedding Service Configuration
|
||||
# ============================================
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
"""
|
||||
)
|
||||
@@ -3,4 +3,84 @@
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
+777
-347
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
||||
# Discover token endpoint
|
||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||
discovery_resp = await client.get(discovery_url)
|
||||
discovery_resp.raise_for_status()
|
||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
||||
token = await self.get_access_token()
|
||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||
|
||||
response = await client.get(
|
||||
|
||||
@@ -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,6 +23,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -141,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()
|
||||
@@ -150,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)
|
||||
|
||||
@@ -285,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,
|
||||
@@ -295,31 +297,12 @@ 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()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Rewrite token_endpoint from public URL to internal Docker URL
|
||||
# Discovery document returns public URLs (e.g., http://localhost:8080/...)
|
||||
# but server-side requests must use internal Docker network (e.g., http://app:80/...)
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_host = oauth_config["nextcloud_host"]
|
||||
internal_parsed = parse_url(internal_host)
|
||||
token_parsed = parse_url(token_endpoint)
|
||||
public_parsed = parse_url(public_issuer)
|
||||
|
||||
if token_parsed.hostname == public_parsed.hostname:
|
||||
# Replace public URL with internal Docker URL
|
||||
token_endpoint = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
||||
logger.info(
|
||||
f"Rewrote token endpoint to internal URL: {token_endpoint}"
|
||||
)
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
@@ -332,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,
|
||||
@@ -400,8 +383,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||
refresh_expires_at = None
|
||||
if refresh_expires_in:
|
||||
import time
|
||||
|
||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||
logger.info(
|
||||
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__)
|
||||
@@ -163,10 +168,6 @@ class RefreshTokenStorage:
|
||||
|
||||
# Run migrations in a worker thread using anyio.to_thread
|
||||
# This allows Alembic to run its own async operations in a separate context
|
||||
from anyio import to_thread
|
||||
|
||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||
|
||||
if not has_alembic:
|
||||
if has_schema:
|
||||
# Stamp existing database without running migrations
|
||||
@@ -830,7 +831,6 @@ class RefreshTokenStorage:
|
||||
resource_id: Resource identifier
|
||||
auth_method: Authentication method used
|
||||
"""
|
||||
import socket
|
||||
|
||||
hostname = socket.gethostname()
|
||||
timestamp = int(time.time())
|
||||
@@ -1240,6 +1240,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:
|
||||
"""
|
||||
|
||||
@@ -25,6 +25,8 @@ import jwt
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
self._http_client = nextcloud_httpx_client(
|
||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||
)
|
||||
return self._http_client
|
||||
@@ -168,37 +170,6 @@ class TokenBrokerService:
|
||||
self._oidc_config = response.json()
|
||||
return self._oidc_config
|
||||
|
||||
def _rewrite_token_endpoint(self, token_endpoint: str) -> str:
|
||||
"""Rewrite token endpoint from public URL to internal Docker URL.
|
||||
|
||||
OIDC discovery documents return public URLs (e.g., http://localhost:8080/...)
|
||||
but server-side requests must use internal Docker network (e.g., http://app:80/...).
|
||||
|
||||
Args:
|
||||
token_endpoint: Token endpoint URL from discovery document
|
||||
|
||||
Returns:
|
||||
Rewritten URL using internal Docker host
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer:
|
||||
return token_endpoint
|
||||
|
||||
internal_parsed = urlparse(self.nextcloud_host)
|
||||
token_parsed = urlparse(token_endpoint)
|
||||
public_parsed = urlparse(public_issuer)
|
||||
|
||||
if token_parsed.hostname == public_parsed.hostname:
|
||||
# Replace public URL with internal Docker URL
|
||||
rewritten = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
||||
logger.info(f"Rewrote token endpoint: {token_endpoint} -> {rewritten}")
|
||||
return rewritten
|
||||
|
||||
return token_endpoint
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a valid Nextcloud access token for the user.
|
||||
@@ -407,7 +378,7 @@ class TokenBrokerService:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
@@ -477,7 +448,7 @@ class TokenBrokerService:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||
token_endpoint = config["token_endpoint"]
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from ..config import get_settings
|
||||
from ..http import nextcloud_httpx_client
|
||||
from .storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
||||
self.storage: Optional[RefreshTokenStorage] = None
|
||||
|
||||
# Create HTTP client
|
||||
self.http_client = httpx.AsyncClient(
|
||||
self.http_client = nextcloud_httpx_client(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Token utility functions for extracting user identity from MCP access tokens.
|
||||
|
||||
Extracted from server/oauth_tools.py to break circular import dependencies
|
||||
between server/ and auth/ layers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import jwt
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"""Extract user_id from the MCP access token (Flow 1).
|
||||
|
||||
Handles both JWT and opaque tokens:
|
||||
- JWT: Decode and extract 'sub' claim
|
||||
- Opaque: Call userinfo endpoint to get 'sub'
|
||||
|
||||
Args:
|
||||
ctx: MCP context with access token
|
||||
|
||||
Returns:
|
||||
user_id extracted from token, or "default_user" as fallback
|
||||
"""
|
||||
# Use MCP SDK's get_access_token() which uses contextvars
|
||||
access_token: AccessToken | None = get_access_token()
|
||||
|
||||
if not access_token or not access_token.token:
|
||||
logger.warning(" ✗ No access token found via get_access_token()")
|
||||
return "default_user"
|
||||
|
||||
token = access_token.token
|
||||
is_jwt = "." in token and token.count(".") >= 2
|
||||
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
|
||||
|
||||
# Try JWT decode first
|
||||
if is_jwt:
|
||||
try:
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
user_id = payload.get("sub", "unknown")
|
||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Opaque token - call userinfo endpoint
|
||||
logger.info(" Opaque token detected, calling userinfo endpoint...")
|
||||
try:
|
||||
# Get userinfo endpoint from OIDC discovery
|
||||
oidc_discovery_uri = os.getenv(
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
userinfo_endpoint = discovery.get("userinfo_endpoint")
|
||||
|
||||
if userinfo_endpoint:
|
||||
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
|
||||
if userinfo:
|
||||
user_id = userinfo.get("sub", "unknown")
|
||||
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
|
||||
return user_id
|
||||
else:
|
||||
logger.error(" ✗ Userinfo query failed")
|
||||
else:
|
||||
logger.error(" ✗ No userinfo_endpoint available")
|
||||
except Exception as e:
|
||||
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
|
||||
|
||||
# Fallback
|
||||
logger.warning(" Using fallback user_id: default_user")
|
||||
return "default_user"
|
||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
record_oauth_token_validation,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||
|
||||
# Common components for all modes
|
||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
||||
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||
|
||||
# JWT verification support
|
||||
self.jwks_client: PyJWKClient | None = None
|
||||
@@ -117,6 +119,71 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
# Both modes do the same validation (MCP audience only)
|
||||
return await self._verify_mcp_audience(token)
|
||||
|
||||
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Verify token for management API access (ADR-018 NC PHP app integration).
|
||||
|
||||
This verification accepts ANY valid Nextcloud OIDC token, not just tokens
|
||||
with MCP server audience. This is needed because:
|
||||
- Astrolabe (NC PHP app) uses its own OAuth client with Nextcloud OIDC
|
||||
- Tokens from Astrolabe have Astrolabe's client_id as audience
|
||||
- MCP server's management API should accept these tokens
|
||||
|
||||
Security Model:
|
||||
~~~~~~~~~~~~~~~~
|
||||
This relaxed audience validation is secure because:
|
||||
|
||||
1. **Authentication layer** (this method):
|
||||
- Verifies token signature against Nextcloud's JWKS (cryptographic proof)
|
||||
- Verifies token is not expired
|
||||
- Extracts user identity from validated token claims
|
||||
|
||||
2. **Authorization layer** (management API endpoints):
|
||||
- EVERY endpoint verifies: token.sub == requested_resource_owner
|
||||
- Example: GET /users/{user_id}/session checks token_user_id == path_user_id
|
||||
- Users can ONLY access their own resources, never another user's
|
||||
|
||||
3. **Attack scenario analysis**:
|
||||
- Attacker with stolen token for App A cannot access user B's data
|
||||
- Token's `sub` claim is cryptographically bound to a specific user
|
||||
- Authorization layer rejects cross-user access attempts (403 Forbidden)
|
||||
|
||||
4. **Why audience validation isn't needed here**:
|
||||
- Audience validation prevents token confusion attacks across services
|
||||
- But management API authorization already gates access per-user
|
||||
- A token valid for "astrolabe" is still bound to user X, not user Y
|
||||
|
||||
Args:
|
||||
token: Bearer token to verify
|
||||
|
||||
Returns:
|
||||
AccessToken if valid (regardless of audience), None otherwise
|
||||
"""
|
||||
# Check cache first (using separate cache key to avoid mixing with MCP tokens)
|
||||
cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}"
|
||||
if cache_key in self._token_cache:
|
||||
userinfo, expiry = self._token_cache[cache_key]
|
||||
if time.time() < expiry:
|
||||
logger.debug("Management API token found in cache")
|
||||
oauth_token_cache_hits_total.labels(hit="true").inc()
|
||||
username = userinfo.get("sub") or userinfo.get("preferred_username")
|
||||
scope_string = userinfo.get("scope", "")
|
||||
scopes = scope_string.split() if scope_string else []
|
||||
return AccessToken(
|
||||
token=token,
|
||||
client_id=userinfo.get("client_id", ""),
|
||||
scopes=scopes,
|
||||
expires_at=int(expiry),
|
||||
resource=username,
|
||||
)
|
||||
else:
|
||||
del self._token_cache[cache_key]
|
||||
|
||||
oauth_token_cache_hits_total.labels(hit="false").inc()
|
||||
|
||||
# Verify token without audience check
|
||||
return await self._verify_without_audience_check(token, cache_key)
|
||||
|
||||
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||
"""
|
||||
Validate token has MCP audience.
|
||||
@@ -186,6 +253,78 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
record_oauth_token_validation(validation_method, "error")
|
||||
return None
|
||||
|
||||
async def _verify_without_audience_check(
|
||||
self, token: str, cache_key: str
|
||||
) -> AccessToken | None:
|
||||
"""
|
||||
Verify token validity without checking MCP audience or issuer.
|
||||
|
||||
Used for management API where tokens from Astrolabe (NC PHP app) need to
|
||||
be accepted. These tokens are issued by Nextcloud OIDC to Astrolabe's
|
||||
OAuth client, not MCP server's client.
|
||||
|
||||
What we verify:
|
||||
- ✓ Token signature (cryptographic proof token is from Nextcloud OIDC)
|
||||
- ✓ Token expiration (not expired)
|
||||
- ✓ Token structure (valid JWT format)
|
||||
|
||||
What we skip:
|
||||
- ✗ Audience check (token may have Astrolabe's audience, not MCP's)
|
||||
- ✗ Issuer check (token may have internal Nextcloud URL as issuer)
|
||||
|
||||
Security guarantee:
|
||||
- Authorization is enforced by management API endpoints
|
||||
- Each endpoint verifies: token.sub == requested_resource_owner
|
||||
- See verify_token_for_management_api() docstring for full security model
|
||||
|
||||
Args:
|
||||
token: Bearer token to verify
|
||||
cache_key: Cache key for storing validation result
|
||||
|
||||
Returns:
|
||||
AccessToken if valid, None otherwise
|
||||
"""
|
||||
validation_method = "unknown"
|
||||
try:
|
||||
# Attempt JWT verification first
|
||||
# Skip issuer check for management API tokens (may have internal URL)
|
||||
if self._is_jwt_format(token) and self.jwks_client:
|
||||
validation_method = "jwt"
|
||||
payload = await self._verify_jwt_signature(
|
||||
token, skip_issuer_check=True
|
||||
)
|
||||
if payload:
|
||||
record_oauth_token_validation("jwt", "valid")
|
||||
else:
|
||||
record_oauth_token_validation("jwt", "invalid")
|
||||
return None
|
||||
else:
|
||||
# Fall back to introspection for opaque tokens
|
||||
validation_method = "introspect"
|
||||
payload = await self._introspect_token(token)
|
||||
if payload:
|
||||
record_oauth_token_validation("introspect", "valid")
|
||||
else:
|
||||
record_oauth_token_validation("introspect", "invalid")
|
||||
return None
|
||||
|
||||
# Check payload is valid
|
||||
if not payload:
|
||||
return None
|
||||
|
||||
# Skip audience validation - any valid Nextcloud token is accepted
|
||||
logger.debug(
|
||||
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
|
||||
)
|
||||
|
||||
# Cache and return the token
|
||||
return self._create_access_token_with_cache_key(token, payload, cache_key)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Management API token verification failed: {e}")
|
||||
record_oauth_token_validation(validation_method, "error")
|
||||
return None
|
||||
|
||||
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if token has MCP audience.
|
||||
@@ -230,12 +369,15 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
"""
|
||||
return "." in token and token.count(".") == 2
|
||||
|
||||
async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None:
|
||||
async def _verify_jwt_signature(
|
||||
self, token: str, skip_issuer_check: bool = False
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Verify JWT token with signature validation using JWKS.
|
||||
|
||||
Args:
|
||||
token: JWT token to verify
|
||||
skip_issuer_check: If True, skip issuer validation (for management API tokens)
|
||||
|
||||
Returns:
|
||||
Decoded payload if valid, None if invalid
|
||||
@@ -248,25 +390,22 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
# Verify and decode JWT
|
||||
# Note: We don't validate audience here - that's done separately based on mode
|
||||
# Issuer validation can be skipped for management API tokens (from Astrolabe)
|
||||
should_verify_issuer = (
|
||||
not skip_issuer_check
|
||||
and hasattr(self.settings, "oidc_issuer")
|
||||
and self.settings.oidc_issuer
|
||||
)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=(
|
||||
self.settings.oidc_issuer
|
||||
if hasattr(self.settings, "oidc_issuer")
|
||||
else None
|
||||
),
|
||||
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
|
||||
options={
|
||||
"verify_signature": True,
|
||||
"verify_exp": True,
|
||||
"verify_iat": True,
|
||||
"verify_iss": (
|
||||
True
|
||||
if hasattr(self.settings, "oidc_issuer")
|
||||
and self.settings.oidc_issuer
|
||||
else False
|
||||
),
|
||||
"verify_iss": should_verify_issuer,
|
||||
"verify_aud": False, # We handle audience validation separately
|
||||
},
|
||||
)
|
||||
@@ -358,6 +497,24 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
token: The bearer token
|
||||
payload: Validated token payload
|
||||
|
||||
Returns:
|
||||
AccessToken object or None if required fields missing
|
||||
"""
|
||||
# Use default cache key (hash of token)
|
||||
cache_key = hashlib.sha256(token.encode()).hexdigest()
|
||||
return self._create_access_token_with_cache_key(token, payload, cache_key)
|
||||
|
||||
def _create_access_token_with_cache_key(
|
||||
self, token: str, payload: dict[str, Any], cache_key: str
|
||||
) -> AccessToken | None:
|
||||
"""
|
||||
Create AccessToken object from validated token payload with custom cache key.
|
||||
|
||||
Args:
|
||||
token: The bearer token
|
||||
payload: Validated token payload
|
||||
cache_key: Key to use for caching (allows separate caches for MCP vs management API)
|
||||
|
||||
Returns:
|
||||
AccessToken object or None if required fields missing
|
||||
"""
|
||||
@@ -382,14 +539,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
logger.warning("No 'exp' claim in token, using default TTL")
|
||||
exp = int(time.time() + self.cache_ttl)
|
||||
|
||||
# Cache the result
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
# Cache the result with the provided key
|
||||
userinfo = {
|
||||
"sub": username,
|
||||
"scope": scope_string,
|
||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
||||
}
|
||||
self._token_cache[token_hash] = (userinfo, exp)
|
||||
self._token_cache[cache_key] = (userinfo, exp)
|
||||
|
||||
return AccessToken(
|
||||
token=token,
|
||||
|
||||
@@ -9,16 +9,21 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
|
||||
|
||||
import logging
|
||||
import os
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import BasicAuth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,8 +58,6 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudC
|
||||
if not all([nextcloud_host, username, password]):
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
from httpx import BasicAuth
|
||||
|
||||
assert nextcloud_host is not None
|
||||
assert username is not None
|
||||
assert password is not None
|
||||
@@ -105,9 +108,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
"status": str, # "syncing" or "idle"
|
||||
}
|
||||
"""
|
||||
# Check if vector sync is enabled
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
if not vector_sync_enabled:
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -126,10 +129,10 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
|
||||
get_qdrant_client,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection
|
||||
@@ -257,7 +260,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -290,7 +293,7 @@ async def _query_idp_userinfo(
|
||||
User info dictionary from IdP, or None if query fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||
@@ -385,8 +388,6 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
||||
return user_context
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(f"Error retrieving user info: {e}")
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
return {
|
||||
@@ -432,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
# Check if user is admin (for Webhooks tab)
|
||||
is_admin = False
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
|
||||
# Get authenticated Nextcloud client
|
||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||
@@ -472,8 +471,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
# Get Nextcloud host for generating links to apps (used by viz tab)
|
||||
# Use public issuer URL if available (for browser-accessible links),
|
||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
nextcloud_host_for_links = (
|
||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
||||
@@ -635,7 +632,9 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
|
||||
settings = get_settings()
|
||||
vector_sync_enabled = settings.vector_sync_enabled
|
||||
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
|
||||
@@ -15,18 +15,25 @@ import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
import numpy as np
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
from nextcloud_mcp_server.vector.pca import PCA
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
@@ -136,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
# Get authenticated HTTP client from session
|
||||
# In BasicAuth mode: uses username/password from session
|
||||
# In OAuth mode: uses access token from session
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
|
||||
with trace_operation("vector_viz.get_auth_client"):
|
||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||
|
||||
@@ -352,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
@@ -396,8 +397,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
coords = pca.fit_transform(vectors)
|
||||
return coords, pca
|
||||
|
||||
import anyio
|
||||
|
||||
with trace_operation(
|
||||
"vector_viz.pca_compute",
|
||||
attributes={
|
||||
@@ -556,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
doc_id_int = int(doc_id)
|
||||
|
||||
# Get authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
# Use context expansion module to fetch chunk with surrounding context
|
||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
@@ -595,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
page_number = None
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
username = request.user.display_name
|
||||
|
||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
||||
get_preset,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
assert username is not None and password is not None # Type narrowing
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
timeout=30.0,
|
||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
if not nextcloud_host:
|
||||
raise RuntimeError("Nextcloud host not configured")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
|
||||
@@ -6,6 +6,13 @@ import uvicorn
|
||||
from nextcloud_mcp_server.config import (
|
||||
get_settings,
|
||||
)
|
||||
from nextcloud_mcp_server.migrations import (
|
||||
create_migration,
|
||||
downgrade_database,
|
||||
get_current_revision,
|
||||
show_migration_history,
|
||||
upgrade_database,
|
||||
)
|
||||
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||
|
||||
from .app import get_app
|
||||
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
|
||||
# Use custom database path
|
||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import upgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Upgrading database to revision: {revision}")
|
||||
upgrade_database(database_path, revision)
|
||||
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
|
||||
# Downgrade to base (empty database)
|
||||
$ nextcloud-mcp-server db downgrade --revision base
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import downgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Downgrading database to revision: {revision}")
|
||||
downgrade_database(database_path, revision)
|
||||
@@ -362,8 +365,6 @@ def current(database_path: str):
|
||||
Example:
|
||||
$ nextcloud-mcp-server db current
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import get_current_revision
|
||||
|
||||
try:
|
||||
revision = get_current_revision(database_path)
|
||||
if revision:
|
||||
@@ -397,8 +398,6 @@ def history(database_path: str):
|
||||
Example:
|
||||
$ nextcloud-mcp-server db history
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import show_migration_history
|
||||
|
||||
try:
|
||||
click.echo("Migration history:")
|
||||
show_migration_history(database_path)
|
||||
@@ -421,8 +420,6 @@ def migrate(message: str):
|
||||
|
||||
Note: You must manually edit the generated migration file to add SQL statements.
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import create_migration
|
||||
|
||||
try:
|
||||
click.echo(f"Creating new migration: {message}")
|
||||
create_migration(message)
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
from httpx import (
|
||||
AsyncBaseTransport,
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
@@ -13,6 +12,7 @@ from httpx import (
|
||||
)
|
||||
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from ..http import nextcloud_httpx_transport
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
timeout=Timeout(timeout=30, connect=5),
|
||||
)
|
||||
@@ -113,7 +113,7 @@ class NextcloudClient:
|
||||
Returns:
|
||||
NextcloudClient configured with bearer token authentication
|
||||
"""
|
||||
from ..auth import BearerAuth
|
||||
from ..auth import BearerAuth # noqa: PLC0415
|
||||
|
||||
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||
|
||||
@@ -6,12 +6,16 @@ import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anyio
|
||||
from caldav.async_collection import AsyncCalendar
|
||||
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||
from caldav.async_davclient import AsyncDAVClient
|
||||
from caldav.elements import cdav, dav
|
||||
from httpx import Auth
|
||||
from icalendar import Alarm, Calendar, vRecur
|
||||
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||
from icalendar import Event as ICalEvent
|
||||
from icalendar import Todo as ICalTodo
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
from ..config import get_nextcloud_ssl_verify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +38,7 @@ class CalendarClient:
|
||||
url=f"{base_url}/remote.php/dav/",
|
||||
username=username,
|
||||
auth=auth,
|
||||
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
|
||||
)
|
||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||
|
||||
@@ -100,8 +105,6 @@ class CalendarClient:
|
||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
@@ -255,18 +258,35 @@ class CalendarClient:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all events using caldav library (now with proper filter)
|
||||
events = await calendar.events()
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
# Expand is only used when both bounds are provided
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
expanded = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
if event.data:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
# multiple VEVENTs (one per recurrence occurrence)
|
||||
for event_dict in self._parse_all_ical_events(event.data):
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
else:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
@@ -274,6 +294,53 @@ class CalendarClient:
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
return result
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: Optional[dt.datetime] = None,
|
||||
end_datetime: Optional[dt.datetime] = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||
|
||||
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
# When both bounds are provided, request server-side expansion of
|
||||
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||
data = cdav.CalendarData()
|
||||
if start_datetime and end_datetime:
|
||||
data += cdav.Expand(start_datetime, end_datetime)
|
||||
|
||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||
|
||||
body = etree.tostring(
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||
for href, props in response_data.items():
|
||||
if href == str(calendar.url):
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -583,7 +650,7 @@ class CalendarClient:
|
||||
# Add categories
|
||||
categories = event_data.get("categories", "")
|
||||
if categories:
|
||||
event.add("categories", categories.split(","))
|
||||
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||
|
||||
# Add priority and status
|
||||
priority = event_data.get("priority", 5)
|
||||
@@ -633,75 +700,92 @@ class CalendarClient:
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||
"""Extract event data from a single VEVENT component.
|
||||
|
||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||
"""
|
||||
event_data: Dict[str, Any] = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract event data."""
|
||||
"""Parse iCalendar text and extract the first event."""
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
||||
dtend.dt, dt.datetime
|
||||
):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(
|
||||
str(a).replace("mailto:", "") for a in attendee
|
||||
)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
return self._extract_vevent_data(component)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar event: {e}")
|
||||
return None
|
||||
|
||||
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract ALL event occurrences.
|
||||
|
||||
Used with server-side expansion where a single VCALENDAR contains
|
||||
multiple VEVENT components (one per recurrence occurrence).
|
||||
"""
|
||||
results: list[Dict[str, Any]] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
results.append(self._extract_vevent_data(component))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar events: {e}")
|
||||
return results
|
||||
|
||||
def _merge_ical_properties(
|
||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||
) -> str:
|
||||
@@ -727,6 +811,50 @@ class CalendarClient:
|
||||
if "url" in event_data:
|
||||
component["URL"] = event_data["url"]
|
||||
|
||||
# Handle categories
|
||||
if "categories" in event_data:
|
||||
categories_str = event_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
elif "CATEGORIES" in component:
|
||||
del component["CATEGORIES"]
|
||||
|
||||
# Handle recurrence rule
|
||||
if "recurrence_rule" in event_data:
|
||||
rrule_str = event_data["recurrence_rule"]
|
||||
if rrule_str:
|
||||
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||
elif "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
|
||||
# Handle attendees
|
||||
if "attendees" in event_data:
|
||||
attendees_str = event_data["attendees"]
|
||||
# Remove all existing attendees first
|
||||
while "ATTENDEE" in component:
|
||||
del component["ATTENDEE"]
|
||||
if attendees_str:
|
||||
for email in attendees_str.split(","):
|
||||
if email.strip():
|
||||
component.add("attendee", f"mailto:{email.strip()}")
|
||||
|
||||
# Handle reminder (VALARM)
|
||||
if "reminder_minutes" in event_data:
|
||||
component.subcomponents = [
|
||||
sub
|
||||
for sub in component.subcomponents
|
||||
if sub.name != "VALARM"
|
||||
]
|
||||
minutes = event_data["reminder_minutes"]
|
||||
if minutes > 0:
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Event reminder")
|
||||
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||
component.add_component(alarm)
|
||||
|
||||
# Handle dates
|
||||
if "start_datetime" in event_data:
|
||||
start_str = event_data["start_datetime"]
|
||||
@@ -757,8 +885,6 @@ class CalendarClient:
|
||||
component["DTEND"] = end_dt
|
||||
|
||||
# Update timestamps
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||
component["DTSTAMP"] = vDDDTypes(now)
|
||||
@@ -823,24 +949,18 @@ class CalendarClient:
|
||||
# Due date
|
||||
due = todo_data.get("due", "")
|
||||
if due:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
due_dt = self._ensure_timezone_aware(due)
|
||||
todo.add("due", vDDDTypes(due_dt))
|
||||
|
||||
# Start date
|
||||
dtstart = todo_data.get("dtstart", "")
|
||||
if dtstart:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
start_dt = self._ensure_timezone_aware(dtstart)
|
||||
todo.add("dtstart", vDDDTypes(start_dt))
|
||||
|
||||
# Completed timestamp
|
||||
completed = todo_data.get("completed", "")
|
||||
if completed:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
completed_dt = self._ensure_timezone_aware(completed)
|
||||
todo.add("completed", vDDDTypes(completed_dt))
|
||||
|
||||
@@ -929,9 +1049,6 @@ class CalendarClient:
|
||||
component["PERCENT-COMPLETE"] = percent_value
|
||||
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
||||
|
||||
# Import vDDDTypes at the beginning for datetime formatting
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
# Handle due date
|
||||
if "due" in todo_data:
|
||||
due_str = todo_data["due"]
|
||||
@@ -960,7 +1077,9 @@ class CalendarClient:
|
||||
if "categories" in todo_data:
|
||||
categories_str = todo_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = categories_str.split(",")
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||
|
||||
# Update timestamps
|
||||
|
||||
@@ -274,6 +274,7 @@ class ContactsClient(BaseNextcloudClient):
|
||||
"nickname": contact.nickname,
|
||||
"birthday": contact.bday,
|
||||
"email": contact.email,
|
||||
"tel": contact.tel,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
from httpx import Timeout
|
||||
|
||||
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of matching recipe stubs
|
||||
"""
|
||||
# URL encode the query
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_query = quote(query)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of recipe stubs in the category
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_category = quote(category)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
New category name
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_old_name = quote(old_name)
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of recipe stubs matching the keywords
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Join keywords with commas
|
||||
keywords_str = ",".join(keywords)
|
||||
encoded_keywords = quote(keywords_str)
|
||||
|
||||
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
|
||||
archived: Optional[bool] = None,
|
||||
done: Optional[str] = None,
|
||||
) -> None:
|
||||
# First, get the current card to use existing values for required fields
|
||||
# Deck PUT API is a full replacement - all required fields must be sent.
|
||||
# Fetch current card to preserve values for fields not being updated.
|
||||
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||
|
||||
json_data = {}
|
||||
if title is not None:
|
||||
json_data["title"] = title
|
||||
if description is not None:
|
||||
json_data["description"] = description
|
||||
# Type is required by the API, use provided or keep current
|
||||
json_data["type"] = type if type is not None else current_card.type
|
||||
# Owner is required by the API, use provided or keep current
|
||||
json_data["owner"] = (
|
||||
owner
|
||||
if owner is not None
|
||||
else (
|
||||
current_card.owner
|
||||
if isinstance(current_card.owner, str)
|
||||
else current_card.owner.uid
|
||||
if hasattr(current_card.owner, "uid")
|
||||
else current_card.owner.primaryKey
|
||||
)
|
||||
)
|
||||
# Build payload with required fields always included
|
||||
json_data = {
|
||||
# Title is required by the API
|
||||
"title": title if title is not None else current_card.title,
|
||||
# Type is required by the API
|
||||
"type": type if type is not None else current_card.type,
|
||||
# Owner is required by the API (model validator ensures it's a string)
|
||||
"owner": owner if owner is not None else current_card.owner,
|
||||
# Description must be sent to preserve it (PUT clears omitted fields)
|
||||
"description": description
|
||||
if description is not None
|
||||
else (current_card.description or ""),
|
||||
}
|
||||
if order is not None:
|
||||
json_data["order"] = order
|
||||
if duedate is not None:
|
||||
@@ -391,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
||||
order: int,
|
||||
target_stack_id: int,
|
||||
) -> None:
|
||||
# Use the non-API route /cards/{cardId}/reorder which correctly reads
|
||||
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
|
||||
# has a parameter conflict where URL stackId overrides body stackId.
|
||||
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
|
||||
json_data = {"order": order, "stackId": target_stack_id}
|
||||
headers = self._get_deck_headers()
|
||||
await self._make_request(
|
||||
"PUT",
|
||||
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
|
||||
f"/apps/deck/cards/{card_id}/reorder",
|
||||
json=json_data,
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
# Labels
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,9 +158,6 @@ class NotesClient(BaseNextcloudClient):
|
||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||
)
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
await webdav_client.cleanup_old_attachment_directory(
|
||||
note_id=note_id, old_category=old_note.get("category", "")
|
||||
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
|
||||
|
||||
# Clean up attachment directories
|
||||
try:
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
|
||||
for cat in potential_categories:
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
continue
|
||||
|
||||
# Decode href path and extract the file path
|
||||
from urllib.parse import unquote
|
||||
|
||||
href_path = unquote(href_elem.text)
|
||||
# Remove WebDAV prefix to get user-relative path
|
||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
# Parse last modified timestamp
|
||||
last_modified_timestamp = None
|
||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||
last_modified_timestamp = int(dt.timestamp())
|
||||
|
||||
+211
-30
@@ -1,9 +1,11 @@
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
@@ -163,25 +165,36 @@ def get_document_processor_config() -> dict[str, Any]:
|
||||
class Settings:
|
||||
"""Application settings from environment variables."""
|
||||
|
||||
# Deployment mode (ADR-021: explicit mode selection)
|
||||
# Optional: 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
|
||||
deployment_mode: str | None = None
|
||||
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url: Optional[str] = None
|
||||
oidc_client_id: Optional[str] = None
|
||||
oidc_client_secret: Optional[str] = None
|
||||
oidc_issuer: Optional[str] = None
|
||||
oidc_discovery_url: str | None = None
|
||||
oidc_client_id: str | None = None
|
||||
oidc_client_secret: str | None = None
|
||||
oidc_issuer: str | None = None
|
||||
|
||||
# Nextcloud settings
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
nextcloud_host: str | None = None
|
||||
nextcloud_username: str | None = None
|
||||
nextcloud_password: str | None = None
|
||||
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
nextcloud_ca_bundle: str | None = None
|
||||
|
||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
|
||||
|
||||
# Token verification endpoints
|
||||
jwks_uri: Optional[str] = None
|
||||
introspection_uri: Optional[str] = None
|
||||
userinfo_uri: Optional[str] = None
|
||||
jwks_uri: str | None = None
|
||||
introspection_uri: str | None = None
|
||||
userinfo_uri: str | None = None
|
||||
|
||||
# Progressive Consent settings (always enabled - no flag needed)
|
||||
enable_token_exchange: bool = False
|
||||
@@ -192,6 +205,9 @@ class Settings:
|
||||
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||
enable_multi_user_basic_auth: bool = False
|
||||
|
||||
# Login Flow v2 settings (ADR-022)
|
||||
enable_login_flow: bool = False
|
||||
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||
|
||||
@@ -202,8 +218,8 @@ class Settings:
|
||||
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
|
||||
# Used for webhook tracking (all modes) and OAuth token storage.
|
||||
# Defaults to /tmp/tokens.db
|
||||
token_encryption_key: Optional[str] = None
|
||||
token_storage_db: Optional[str] = None
|
||||
token_encryption_key: str | None = None
|
||||
token_storage_db: str | None = None
|
||||
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled: bool = False
|
||||
@@ -213,19 +229,19 @@ class Settings:
|
||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: Optional[str] = None
|
||||
qdrant_url: str | None = None # Network mode: http://qdrant:6333
|
||||
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
|
||||
qdrant_api_key: str | None = None
|
||||
qdrant_collection: str = "nextcloud_content"
|
||||
|
||||
# Ollama settings (for embeddings)
|
||||
ollama_base_url: Optional[str] = None
|
||||
ollama_base_url: str | None = None
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_api_key: str | None = None
|
||||
openai_base_url: str | None = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
@@ -235,7 +251,7 @@ class Settings:
|
||||
# Observability settings
|
||||
metrics_enabled: bool = True
|
||||
metrics_port: int = 9090
|
||||
otel_exporter_otlp_endpoint: Optional[str] = None
|
||||
otel_exporter_otlp_endpoint: str | None = None
|
||||
otel_exporter_verify_ssl: bool = False
|
||||
otel_service_name: str = "nextcloud-mcp-server"
|
||||
otel_traces_sampler: str = "always_on"
|
||||
@@ -245,9 +261,23 @@ class Settings:
|
||||
log_include_trace_context: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate Qdrant configuration and set defaults."""
|
||||
"""Validate configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Validate SSL/TLS configuration
|
||||
if not self.nextcloud_verify_ssl:
|
||||
logger.warning(
|
||||
"NEXTCLOUD_VERIFY_SSL is disabled. "
|
||||
"TLS certificate verification is turned off for all Nextcloud connections. "
|
||||
"This is insecure and should only be used for development/testing."
|
||||
)
|
||||
if self.nextcloud_ca_bundle:
|
||||
if not os.path.isfile(self.nextcloud_ca_bundle):
|
||||
raise ValueError(
|
||||
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||
)
|
||||
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
|
||||
|
||||
# Ensure mutual exclusivity
|
||||
if self.qdrant_url and self.qdrant_location:
|
||||
raise ValueError(
|
||||
@@ -331,7 +361,6 @@ class Settings:
|
||||
Returns:
|
||||
Collection name string
|
||||
"""
|
||||
import socket
|
||||
|
||||
# Use explicit override if user configured non-default value
|
||||
if self.qdrant_collection != "nextcloud_content":
|
||||
@@ -350,6 +379,131 @@ class Settings:
|
||||
|
||||
return f"{deployment_id}-{model_name}"
|
||||
|
||||
# ADR-021: Property aliases for new naming convention
|
||||
# These provide the new names while maintaining backward compatibility with old field names
|
||||
|
||||
@property
|
||||
def enable_semantic_search(self) -> bool:
|
||||
"""Semantic search enabled (ADR-021 alias for vector_sync_enabled)."""
|
||||
return self.vector_sync_enabled
|
||||
|
||||
@property
|
||||
def enable_background_operations(self) -> bool:
|
||||
"""Background operations enabled (ADR-021 alias for enable_offline_access)."""
|
||||
return self.enable_offline_access
|
||||
|
||||
|
||||
def _get_semantic_search_enabled() -> bool:
|
||||
"""Get semantic search enabled status, supporting both old and new variable names.
|
||||
|
||||
Supports:
|
||||
- ENABLE_SEMANTIC_SEARCH (new, preferred)
|
||||
- VECTOR_SYNC_ENABLED (old, deprecated)
|
||||
|
||||
Returns:
|
||||
True if semantic search should be enabled
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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 and will be removed in v1.0.0."
|
||||
)
|
||||
elif old_value and not new_value:
|
||||
logger.warning(
|
||||
"VECTOR_SYNC_ENABLED is deprecated. "
|
||||
"Please use ENABLE_SEMANTIC_SEARCH instead. "
|
||||
"Support for VECTOR_SYNC_ENABLED will be removed in v1.0.0."
|
||||
)
|
||||
|
||||
return new_value or old_value
|
||||
|
||||
|
||||
def _is_multi_user_mode() -> bool:
|
||||
"""Detect if this is a multi-user deployment mode.
|
||||
|
||||
Multi-user modes are:
|
||||
- Multi-user BasicAuth (ENABLE_MULTI_USER_BASIC_AUTH=true)
|
||||
- OAuth Single-Audience (no username/password set)
|
||||
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
Single-user modes are:
|
||||
- Single-user BasicAuth (username and password both set)
|
||||
- Smithery Stateless (SMITHERY_DEPLOYMENT=true)
|
||||
|
||||
Returns:
|
||||
True if multi-user mode detected
|
||||
"""
|
||||
# Smithery is always single-user (stateless)
|
||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||
return False
|
||||
|
||||
# Multi-user BasicAuth explicitly enabled
|
||||
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
|
||||
return True
|
||||
|
||||
# Token exchange implies OAuth multi-user
|
||||
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
|
||||
return True
|
||||
|
||||
# If both username and password are set, it's single-user BasicAuth
|
||||
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
|
||||
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
|
||||
if has_username and has_password:
|
||||
return False
|
||||
|
||||
# Otherwise, assume OAuth multi-user (default when no credentials provided)
|
||||
return True
|
||||
|
||||
|
||||
def _get_background_operations_enabled() -> bool:
|
||||
"""Get background operations enabled status with auto-enablement for semantic search.
|
||||
|
||||
Supports:
|
||||
- ENABLE_BACKGROUND_OPERATIONS (new, preferred)
|
||||
- ENABLE_OFFLINE_ACCESS (old, deprecated)
|
||||
- Auto-enabled if ENABLE_SEMANTIC_SEARCH=true in multi-user modes
|
||||
|
||||
Returns:
|
||||
True if background operations should be enabled
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check new and old variable names
|
||||
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
|
||||
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
|
||||
|
||||
if explicit and legacy:
|
||||
logger.warning(
|
||||
"Both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS are set. "
|
||||
"Using ENABLE_BACKGROUND_OPERATIONS. "
|
||||
"ENABLE_OFFLINE_ACCESS is deprecated and will be removed in v1.0.0."
|
||||
)
|
||||
elif legacy and not explicit:
|
||||
logger.warning(
|
||||
"ENABLE_OFFLINE_ACCESS is deprecated. "
|
||||
"Please use ENABLE_BACKGROUND_OPERATIONS instead. "
|
||||
"Support for ENABLE_OFFLINE_ACCESS will be removed in v1.0.0."
|
||||
)
|
||||
|
||||
# Auto-enable if semantic search is enabled in multi-user mode
|
||||
semantic_search_enabled = _get_semantic_search_enabled()
|
||||
is_multi_user = _is_multi_user_mode()
|
||||
auto_enabled = semantic_search_enabled and is_multi_user
|
||||
|
||||
if auto_enabled and not (explicit or legacy):
|
||||
logger.info(
|
||||
"Automatically enabled background operations for semantic search in multi-user mode. "
|
||||
"Set ENABLE_BACKGROUND_OPERATIONS=false to disable (this will also disable semantic search)."
|
||||
)
|
||||
|
||||
return explicit or legacy or auto_enabled
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Get application settings from environment variables.
|
||||
@@ -357,7 +511,13 @@ def get_settings() -> Settings:
|
||||
Returns:
|
||||
Settings object with configuration values
|
||||
"""
|
||||
# Get consolidated values with smart dependency resolution
|
||||
enable_semantic_search = _get_semantic_search_enabled()
|
||||
enable_background_operations = _get_background_operations_enabled()
|
||||
|
||||
return Settings(
|
||||
# Deployment mode (ADR-021)
|
||||
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
|
||||
# OAuth/OIDC settings
|
||||
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
||||
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
||||
@@ -367,6 +527,12 @@ def get_settings() -> Settings:
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl=(
|
||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||
),
|
||||
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||
# ADR-005: Token Audience Validation
|
||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||
@@ -378,22 +544,20 @@ def get_settings() -> Settings:
|
||||
enable_token_exchange=(
|
||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||
),
|
||||
enable_offline_access=(
|
||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||
),
|
||||
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
||||
# Multi-user BasicAuth pass-through mode
|
||||
enable_multi_user_basic_auth=(
|
||||
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||
),
|
||||
# Login Flow v2 settings (ADR-022)
|
||||
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||
# Vector sync settings (ADR-007)
|
||||
vector_sync_enabled=(
|
||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
),
|
||||
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
|
||||
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
||||
vector_sync_processor_workers=int(
|
||||
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
||||
@@ -436,3 +600,20 @@ def get_settings() -> Settings:
|
||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||
== "true",
|
||||
)
|
||||
|
||||
|
||||
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
|
||||
"""Return the SSL verification setting for Nextcloud connections.
|
||||
|
||||
Returns:
|
||||
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
|
||||
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
|
||||
- True otherwise (default system CA verification)
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.nextcloud_verify_ssl:
|
||||
return False
|
||||
if settings.nextcloud_ca_bundle:
|
||||
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
|
||||
return ctx
|
||||
return True
|
||||
|
||||
@@ -9,6 +9,7 @@ See ADR-020 for detailed architecture and deployment mode documentation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
@@ -105,15 +106,13 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||
],
|
||||
conditional={
|
||||
"enable_offline_access": [
|
||||
"oidc_client_id",
|
||||
"oidc_client_secret",
|
||||
# OAuth credentials validated separately (lines 397-406) with clearer error message
|
||||
"token_encryption_key",
|
||||
"token_storage_db",
|
||||
],
|
||||
"vector_sync_enabled": [
|
||||
# Requires offline access for background sync
|
||||
"enable_offline_access",
|
||||
],
|
||||
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||
# enables background operations in multi-user modes. No explicit
|
||||
# enable_offline_access setting required.
|
||||
},
|
||||
description="Multi-user deployment with BasicAuth pass-through. "
|
||||
"Users provide credentials in request headers. "
|
||||
@@ -152,9 +151,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||
"token_encryption_key",
|
||||
"token_storage_db",
|
||||
],
|
||||
"vector_sync_enabled": [
|
||||
"enable_offline_access", # Background sync requires refresh tokens
|
||||
],
|
||||
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||
# enables background operations in multi-user modes. No explicit
|
||||
# enable_offline_access setting required.
|
||||
},
|
||||
description="OAuth multi-user deployment with single-audience tokens. "
|
||||
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
||||
@@ -192,9 +191,9 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||
"token_encryption_key",
|
||||
"token_storage_db",
|
||||
],
|
||||
"vector_sync_enabled": [
|
||||
"enable_offline_access",
|
||||
],
|
||||
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||
# enables background operations in multi-user modes. No explicit
|
||||
# enable_offline_access setting required.
|
||||
},
|
||||
description="OAuth multi-user deployment with token exchange (RFC 8693). "
|
||||
"MCP tokens are separate from Nextcloud tokens. "
|
||||
@@ -225,7 +224,8 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||
"""Detect authentication mode from configuration.
|
||||
|
||||
Mode detection priority (most specific to most general):
|
||||
Mode detection priority (ADR-021):
|
||||
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
||||
1. Smithery (explicit flag)
|
||||
2. Token exchange (most specific OAuth mode)
|
||||
3. Multi-user BasicAuth
|
||||
@@ -237,12 +237,41 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||
|
||||
Returns:
|
||||
Detected AuthMode
|
||||
|
||||
Raises:
|
||||
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
||||
"""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ADR-021: Check for explicit deployment mode first
|
||||
if settings.deployment_mode:
|
||||
mode_str = settings.deployment_mode.lower().strip()
|
||||
|
||||
# Map string to AuthMode enum
|
||||
mode_map = {
|
||||
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
|
||||
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
||||
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||
"smithery": AuthMode.SMITHERY_STATELESS,
|
||||
}
|
||||
|
||||
if mode_str not in mode_map:
|
||||
valid_modes = ", ".join(mode_map.keys())
|
||||
raise ValueError(
|
||||
f"Invalid MCP_DEPLOYMENT_MODE: '{settings.deployment_mode}'. "
|
||||
f"Valid values: {valid_modes}"
|
||||
)
|
||||
|
||||
explicit_mode = mode_map[mode_str]
|
||||
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
|
||||
return explicit_mode
|
||||
|
||||
# Auto-detection (existing behavior)
|
||||
# Check for Smithery mode (explicit environment variable)
|
||||
# Note: This checks the environment directly, not settings
|
||||
# because Smithery mode has no settings-based config
|
||||
import os
|
||||
|
||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||
return AuthMode.SMITHERY_STATELESS
|
||||
|
||||
@@ -364,22 +393,20 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
||||
)
|
||||
|
||||
if mode == AuthMode.MULTI_USER_BASIC:
|
||||
# Validate that if offline access enabled, we have OAuth credentials
|
||||
# If background operations enabled, check for OAuth credentials (for app password retrieval)
|
||||
# Allow DCR as fallback, just like OAuth modes
|
||||
if settings.enable_offline_access:
|
||||
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||
errors.append(
|
||||
f"[{mode.value}] NEXTCLOUD_OIDC_CLIENT_ID and "
|
||||
"NEXTCLOUD_OIDC_CLIENT_SECRET are required when "
|
||||
"ENABLE_OFFLINE_ACCESS is enabled (for app password retrieval)"
|
||||
logger.info(
|
||||
f"[{mode.value}] OAuth credentials not configured. "
|
||||
"Will attempt Dynamic Client Registration (DCR) at startup "
|
||||
"(required for app password retrieval via Astrolabe)."
|
||||
)
|
||||
|
||||
# Validate vector sync requirements
|
||||
if settings.vector_sync_enabled and not settings.enable_offline_access:
|
||||
errors.append(
|
||||
f"[{mode.value}] ENABLE_OFFLINE_ACCESS must be enabled when "
|
||||
"VECTOR_SYNC_ENABLED is true (background sync requires "
|
||||
"app passwords or refresh tokens)"
|
||||
)
|
||||
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
|
||||
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
|
||||
# background operations in multi-user modes via smart dependency resolution
|
||||
# in config.py
|
||||
|
||||
# Note: Embedding provider validation removed - Simple provider is always
|
||||
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
|
||||
|
||||
@@ -5,6 +5,12 @@ import logging
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
@@ -74,17 +80,17 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
|
||||
# OAuth token is only used for MCP session identity, not NC API calls
|
||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
|
||||
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
|
||||
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
|
||||
if settings.enable_token_exchange:
|
||||
# Mode 2: Exchange MCP token for Nextcloud token
|
||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||
@@ -131,7 +137,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
@@ -246,3 +252,51 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||
username=username,
|
||||
auth=BasicAuth(username, password),
|
||||
)
|
||||
|
||||
|
||||
async def _get_client_from_login_flow(
|
||||
ctx: Context, nextcloud_host: str
|
||||
) -> NextcloudClient:
|
||||
"""Create NextcloudClient from stored Login Flow v2 app password.
|
||||
|
||||
In Login Flow v2 mode, the OAuth token only provides MCP session identity.
|
||||
Nextcloud API calls always use the stored app password obtained via Login Flow v2.
|
||||
|
||||
Args:
|
||||
ctx: MCP context (used to extract user identity)
|
||||
nextcloud_host: Nextcloud instance URL
|
||||
|
||||
Returns:
|
||||
NextcloudClient with stored app password credentials
|
||||
|
||||
Raises:
|
||||
ProvisioningRequiredError: If no stored app password exists
|
||||
"""
|
||||
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 not user_id or user_id == "default_user":
|
||||
raise ProvisioningRequiredError(
|
||||
"Cannot determine user identity from MCP token."
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
app_data = await storage.get_app_password_with_scopes(user_id)
|
||||
if not app_data:
|
||||
raise ProvisioningRequiredError(
|
||||
"Nextcloud access not provisioned. "
|
||||
"Call nc_auth_provision_access to complete Login Flow."
|
||||
)
|
||||
|
||||
username = app_data.get("username") or user_id
|
||||
|
||||
logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}")
|
||||
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_data["app_password"]),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ import tempfile
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
import anyio
|
||||
|
||||
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
||||
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
||||
# causing it to return a string instead of a list[dict].
|
||||
@@ -95,7 +97,6 @@ class PyMuPDFProcessor(DocumentProcessor):
|
||||
Raises:
|
||||
ProcessorError: If PDF processing fails
|
||||
"""
|
||||
import anyio
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
from fastembed import SparseTextEmbedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -67,7 +68,6 @@ class BM25SparseEmbeddingProvider:
|
||||
Returns:
|
||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool
|
||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||
@@ -82,7 +82,6 @@ class BM25SparseEmbeddingProvider:
|
||||
Returns:
|
||||
List of dictionaries with 'indices' and 'values' for each text
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
|
||||
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Centralized HTTP client factory for Nextcloud connections.
|
||||
|
||||
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
|
||||
these factories to ensure consistent SSL/TLS configuration from environment
|
||||
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_nextcloud_ssl_verify
|
||||
|
||||
|
||||
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
|
||||
|
||||
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
|
||||
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
|
||||
takes precedence if explicitly provided.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncClient()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncClient``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
|
||||
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
|
||||
|
||||
Used by ``NextcloudClient`` which wraps the transport in
|
||||
``AsyncDisableCookieTransport``.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncHTTPTransport``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncHTTPTransport(**kwargs)
|
||||
@@ -6,10 +6,12 @@ provides CLI integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
|
||||
import nextcloud_mcp_server.alembic as alembic_package
|
||||
from alembic import command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -29,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
||||
Returns:
|
||||
Alembic Config object configured for the specified database
|
||||
"""
|
||||
from nextcloud_mcp_server import alembic as alembic_package
|
||||
|
||||
# Use package location (works in both editable and installed modes)
|
||||
if alembic_package.__file__ is None:
|
||||
raise RuntimeError("alembic package __file__ is None")
|
||||
@@ -98,7 +98,6 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
|
||||
Returns:
|
||||
Current revision ID or None if not versioned
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
if database_path is None:
|
||||
database_path = "/app/data/tokens.db"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Pydantic response models for Login Flow v2 auth tools."""
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from nextcloud_mcp_server.models.base import BaseResponse
|
||||
|
||||
|
||||
class ProvisionAccessResponse(BaseResponse):
|
||||
"""Response from nc_auth_provision_access tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Provisioning status: 'login_required', 'already_provisioned', 'declined', 'cancelled', 'error'"
|
||||
)
|
||||
login_url: str | None = Field(
|
||||
None, description="URL to open in browser for Nextcloud login"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
user_id: str | None = Field(None, description="MCP user ID")
|
||||
requested_scopes: list[str] | None = Field(
|
||||
None, description="Scopes requested in this provisioning flow"
|
||||
)
|
||||
|
||||
|
||||
class ProvisionStatusResponse(BaseResponse):
|
||||
"""Response from nc_auth_check_status tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Status: 'provisioned', 'pending', 'not_initiated', 'error'"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
user_id: str | None = Field(None, description="MCP user ID")
|
||||
scopes: list[str] | None = Field(
|
||||
None, description="Granted scopes (None = all scopes)"
|
||||
)
|
||||
username: str | None = Field(None, description="Nextcloud username (loginName)")
|
||||
|
||||
|
||||
class UpdateScopesResponse(BaseResponse):
|
||||
"""Response from nc_auth_update_scopes tool."""
|
||||
|
||||
status: str = Field(
|
||||
description="Status: 'login_required', 'unchanged', 'declined', 'cancelled', 'error'"
|
||||
)
|
||||
login_url: str | None = Field(
|
||||
None, description="URL for re-provisioning with new scopes"
|
||||
)
|
||||
message: str = Field(description="Human-readable status message")
|
||||
previous_scopes: list[str] | None = Field(
|
||||
None, description="Previously granted scopes"
|
||||
)
|
||||
new_scopes: list[str] | None = Field(None, description="Updated scope set")
|
||||
|
||||
|
||||
# All supported application-level scopes (frozenset for O(1) membership tests)
|
||||
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"notes:read",
|
||||
"notes:write",
|
||||
"calendar:read",
|
||||
"calendar:write",
|
||||
"todo:read",
|
||||
"todo:write",
|
||||
"contacts:read",
|
||||
"contacts:write",
|
||||
"files:read",
|
||||
"files:write",
|
||||
"tables:read",
|
||||
"tables:write",
|
||||
"deck:read",
|
||||
"deck:write",
|
||||
"cookbook:read",
|
||||
"cookbook:write",
|
||||
"sharing:read",
|
||||
"sharing:write",
|
||||
"news:read",
|
||||
"news:write",
|
||||
}
|
||||
)
|
||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
||||
status: Optional[str] = Field(
|
||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||
)
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this event"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this event"
|
||||
)
|
||||
|
||||
|
||||
class CalendarEvent(CalendarEventSummary):
|
||||
|
||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
||||
color: str = Field(description="The created label color")
|
||||
|
||||
|
||||
class ListCardsResponse(BaseResponse):
|
||||
"""Response model for listing deck cards."""
|
||||
|
||||
cards: list[DeckCard] = Field(description="List of deck cards")
|
||||
total: int = Field(description="Total number of cards")
|
||||
|
||||
|
||||
class ListLabelsResponse(BaseResponse):
|
||||
"""Response model for listing deck labels."""
|
||||
|
||||
labels: list[DeckLabel] = Field(description="List of deck labels")
|
||||
total: int = Field(description="Total number of labels")
|
||||
|
||||
|
||||
class LabelOperationResponse(StatusResponse):
|
||||
"""Response model for label operations like update/delete."""
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ and resource usage. Metrics are organized by category:
|
||||
- External Dependency Health Metrics
|
||||
"""
|
||||
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
|
||||
from prometheus_client import (
|
||||
Counter,
|
||||
@@ -23,6 +25,8 @@ from prometheus_client import (
|
||||
start_http_server,
|
||||
)
|
||||
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
@@ -423,10 +427,6 @@ def instrument_tool(func):
|
||||
Returns:
|
||||
Wrapped function with metrics and tracing instrumentation
|
||||
"""
|
||||
import functools
|
||||
import time
|
||||
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""Base interfaces and data structures for search algorithms."""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Protocol, runtime_checkable
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class NextcloudClientProtocol(Protocol):
|
||||
@@ -78,13 +85,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
>>> if "note" in types:
|
||||
... # Search notes
|
||||
"""
|
||||
import logging
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
@@ -7,7 +7,14 @@ position markers for better visualization and understanding of search results.
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -31,11 +38,6 @@ async def _get_chunk_from_qdrant(
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -101,11 +103,6 @@ async def _get_chunk_by_index_from_qdrant(
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -162,11 +159,6 @@ async def _get_file_path_from_qdrant(
|
||||
File path string, or None if not found in Qdrant
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -222,11 +214,6 @@ async def _get_deck_metadata_from_qdrant(
|
||||
Dictionary with board_id and stack_id, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -352,8 +339,6 @@ async def get_chunk_with_context(
|
||||
|
||||
# Fetch adjacent chunks for context expansion
|
||||
# Get chunk overlap from config to remove duplicate text
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
chunk_overlap = settings.document_chunk_overlap
|
||||
|
||||
@@ -549,8 +534,6 @@ async def _fetch_document_text(
|
||||
# Extract text from PDF using PyMuPDF
|
||||
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
||||
# This ensures character offsets align between indexed chunks and retrieval
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
logger.debug(f"Extracting text from PDF: {file_path}")
|
||||
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
||||
@@ -586,8 +569,6 @@ async def _fetch_document_text(
|
||||
return None
|
||||
elif doc_type == "news_item":
|
||||
# Fetch news item by ID
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_id))
|
||||
# Reconstruct full content as indexed: title + source + URL + body
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
|
||||
@@ -10,10 +10,16 @@ varies between indexing and rendering.
|
||||
|
||||
import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -77,8 +83,6 @@ class PDFHighlighter:
|
||||
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
||||
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
page_boundaries = []
|
||||
text_parts = []
|
||||
@@ -110,7 +114,6 @@ class PDFHighlighter:
|
||||
full_text = "".join(text_parts)
|
||||
|
||||
# Clean up temp directory and extracted images
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
@@ -590,8 +593,6 @@ class PDFHighlighter:
|
||||
Returns:
|
||||
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
temp_pdf_path = None
|
||||
try:
|
||||
@@ -684,8 +685,6 @@ class PDFHighlighter:
|
||||
# Clean up temp directory and PDF file
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -722,11 +721,6 @@ class PDFHighlighter:
|
||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||
Chunks that fail to highlight are omitted from the result.
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
results: dict[int, tuple[bytes, int, int]] = {}
|
||||
|
||||
if not chunks:
|
||||
@@ -800,9 +794,6 @@ class PDFHighlighter:
|
||||
|
||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||
# This avoids expensive page.get_pixmap() calls per chunk
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# PIL color for bounding box (RGB tuple)
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
|
||||
@@ -0,0 +1,493 @@
|
||||
"""MCP tools for Login Flow v2 authentication (ADR-022).
|
||||
|
||||
Provides tools for users to provision Nextcloud access via Login Flow v2,
|
||||
check provisioning status, and update granted scopes.
|
||||
|
||||
These tools work alongside (not replacing) the existing OAuth provisioning
|
||||
tools during the migration period.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth.elicitation import present_login_url
|
||||
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
|
||||
from nextcloud_mcp_server.auth.scope_authorization import (
|
||||
invalidate_scope_cache,
|
||||
require_scopes,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
||||
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
|
||||
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
|
||||
from nextcloud_mcp_server.models.auth import (
|
||||
ALL_SUPPORTED_SCOPES,
|
||||
ProvisionAccessResponse,
|
||||
ProvisionStatusResponse,
|
||||
UpdateScopesResponse,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def register_auth_tools(mcp: FastMCP) -> None:
|
||||
"""Register Login Flow v2 auth tools with the MCP server."""
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_provision_access",
|
||||
title="Provision Nextcloud Access",
|
||||
description=(
|
||||
"Start Nextcloud Login Flow v2 to obtain an app password. "
|
||||
"This is required before using any Nextcloud tools. "
|
||||
"You will be given a URL to open in your browser to log in."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_provision_access(
|
||||
ctx: Context,
|
||||
scopes: list[str] | None = None,
|
||||
) -> ProvisionAccessResponse:
|
||||
"""Provision Nextcloud access via Login Flow v2.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
|
||||
If not specified, all available scopes are requested.
|
||||
|
||||
Returns:
|
||||
ProvisionAccessResponse with login URL or status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Check if already provisioned
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing:
|
||||
return ProvisionAccessResponse(
|
||||
status="already_provisioned",
|
||||
message=(
|
||||
f"Nextcloud access already provisioned for {user_id}. "
|
||||
f"Scopes: {existing['scopes'] or 'all'}. "
|
||||
f"Use nc_auth_update_scopes to modify permissions."
|
||||
),
|
||||
user_id=user_id,
|
||||
requested_scopes=existing["scopes"],
|
||||
)
|
||||
|
||||
# Determine scopes
|
||||
requested_scopes = scopes if scopes else list(ALL_SUPPORTED_SCOPES)
|
||||
|
||||
# Validate requested scopes
|
||||
invalid_scopes = [s for s in requested_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid_scopes:
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
|
||||
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Initiate Login Flow v2
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured on the server.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2: {e}")
|
||||
return ProvisionAccessResponse(
|
||||
status="error",
|
||||
message=f"Failed to start login flow: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Store the polling session
|
||||
await storage.store_login_flow_session(
|
||||
user_id=user_id,
|
||||
poll_token=init_response.poll_token,
|
||||
poll_endpoint=init_response.poll_endpoint,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
# Present login URL to user via elicitation
|
||||
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||
|
||||
if elicitation_result == "declined":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionAccessResponse(
|
||||
status="declined",
|
||||
message="Login flow declined. Call nc_auth_provision_access again to retry.",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
|
||||
if elicitation_result == "cancelled":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionAccessResponse(
|
||||
status="cancelled",
|
||||
message="Login flow cancelled. Call nc_auth_provision_access again to retry.",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Please open this URL in your browser to log in to Nextcloud:\n\n"
|
||||
f"{init_response.login_url}\n\n"
|
||||
f"After logging in, call nc_auth_check_status to complete provisioning."
|
||||
)
|
||||
|
||||
if elicitation_result == "accepted":
|
||||
message = (
|
||||
"Login acknowledged. Call nc_auth_check_status to verify "
|
||||
"and complete provisioning."
|
||||
)
|
||||
return ProvisionAccessResponse(
|
||||
status="pending",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
return ProvisionAccessResponse(
|
||||
status="login_required",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
user_id=user_id,
|
||||
requested_scopes=requested_scopes,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_check_status",
|
||||
title="Check Nextcloud Access Status",
|
||||
description=(
|
||||
"Check if Nextcloud access has been provisioned. "
|
||||
"If a Login Flow is pending, this will poll for completion. "
|
||||
"Recommended polling interval: 5 seconds."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
idempotentHint=True,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_check_status(
|
||||
ctx: Context,
|
||||
) -> ProvisionStatusResponse:
|
||||
"""Check provisioning status and poll pending Login Flows.
|
||||
|
||||
Returns:
|
||||
ProvisionStatusResponse with current status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Check for existing app password
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing:
|
||||
return ProvisionStatusResponse(
|
||||
status="provisioned",
|
||||
message=f"Nextcloud access is provisioned for {existing.get('username') or user_id}.",
|
||||
user_id=user_id,
|
||||
scopes=existing["scopes"],
|
||||
username=existing.get("username"),
|
||||
)
|
||||
|
||||
# Check for pending login flow session
|
||||
try:
|
||||
session = await storage.get_login_flow_session(user_id)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check login flow session for {user_id}: {e}")
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message=f"Failed to check login flow session: {e}",
|
||||
user_id=user_id,
|
||||
success=False,
|
||||
)
|
||||
if not session:
|
||||
return ProvisionStatusResponse(
|
||||
status="not_initiated",
|
||||
message=(
|
||||
"No provisioning in progress. "
|
||||
"Call nc_auth_provision_access to start."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Poll the Login Flow
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
poll_result = await flow_client.poll(
|
||||
poll_endpoint=session["poll_endpoint"],
|
||||
poll_token=session["poll_token"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to poll Login Flow v2: {e}")
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message=f"Failed to check login status: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
if poll_result.status == "completed":
|
||||
# Store the app password with scopes
|
||||
if poll_result.app_password is None:
|
||||
return ProvisionStatusResponse(
|
||||
status="error",
|
||||
message="Login Flow completed but no app password was returned.",
|
||||
success=False,
|
||||
)
|
||||
await storage.store_app_password_with_scopes(
|
||||
user_id=user_id,
|
||||
app_password=poll_result.app_password,
|
||||
scopes=session.get("requested_scopes"),
|
||||
username=poll_result.login_name,
|
||||
)
|
||||
invalidate_scope_cache(user_id)
|
||||
|
||||
# Clean up the flow session
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
|
||||
return ProvisionStatusResponse(
|
||||
status="provisioned",
|
||||
message=f"Nextcloud access provisioned successfully as {poll_result.login_name}.",
|
||||
user_id=user_id,
|
||||
scopes=session.get("requested_scopes"),
|
||||
username=poll_result.login_name,
|
||||
)
|
||||
|
||||
if poll_result.status == "expired":
|
||||
# Clean up expired session
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return ProvisionStatusResponse(
|
||||
status="not_initiated",
|
||||
message=(
|
||||
"Login flow expired. "
|
||||
"Call nc_auth_provision_access to start a new one."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Still pending
|
||||
return ProvisionStatusResponse(
|
||||
status="pending",
|
||||
message=(
|
||||
"Login flow is still pending. "
|
||||
"Please complete the login in your browser, then call this tool again."
|
||||
),
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
name="nc_auth_update_scopes",
|
||||
title="Update Nextcloud Access Scopes",
|
||||
description=(
|
||||
"Update the scopes for your Nextcloud access. "
|
||||
"This starts a new Login Flow with the combined scope set. "
|
||||
"The current app password remains valid until the new one is obtained."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
@require_scopes("openid")
|
||||
async def nc_auth_update_scopes(
|
||||
ctx: Context,
|
||||
add_scopes: list[str] | None = None,
|
||||
remove_scopes: list[str] | None = None,
|
||||
) -> UpdateScopesResponse:
|
||||
"""Update granted scopes by re-provisioning with merged scope set.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
add_scopes: Scopes to add to the current set
|
||||
remove_scopes: Scopes to remove from the current set
|
||||
|
||||
Returns:
|
||||
UpdateScopesResponse with new login URL or status
|
||||
"""
|
||||
user_id = await extract_user_id_from_token(ctx)
|
||||
if user_id == "default_user":
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Could not determine user identity from MCP token.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
if not add_scopes and not remove_scopes:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Provide add_scopes and/or remove_scopes to update.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
storage = await get_shared_storage()
|
||||
|
||||
# Get current state - require existing provisioning
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
if existing is None:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Not provisioned. Call nc_auth_provision_access first.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
previous_scopes = existing["scopes"]
|
||||
|
||||
# Compute new scope set
|
||||
current_set = (
|
||||
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||
)
|
||||
if add_scopes:
|
||||
invalid = [s for s in add_scopes if s not in ALL_SUPPORTED_SCOPES]
|
||||
if invalid:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message=f"Invalid scopes: {', '.join(invalid)}",
|
||||
success=False,
|
||||
)
|
||||
current_set.update(add_scopes)
|
||||
if remove_scopes:
|
||||
current_set -= set(remove_scopes)
|
||||
|
||||
new_scopes = sorted(current_set)
|
||||
|
||||
if not new_scopes:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="Cannot remove all scopes. At least one scope must remain.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# No-op detection: skip Login Flow if scopes are unchanged
|
||||
previous_scopes_set = (
|
||||
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
|
||||
)
|
||||
if set(new_scopes) == previous_scopes_set:
|
||||
return UpdateScopesResponse(
|
||||
status="unchanged",
|
||||
message="Requested scopes match current scopes. No changes needed.",
|
||||
previous_scopes=previous_scopes,
|
||||
new_scopes=new_scopes,
|
||||
)
|
||||
|
||||
# Initiate new Login Flow v2
|
||||
# Note: existing app password stays valid until the new flow completes.
|
||||
# store_app_password_with_scopes() does an upsert, so the old password
|
||||
# is replaced atomically when nc_auth_check_status stores the new one.
|
||||
settings = get_settings()
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
if not nextcloud_host:
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message="NEXTCLOUD_HOST not configured.",
|
||||
success=False,
|
||||
)
|
||||
|
||||
try:
|
||||
flow_client = LoginFlowV2Client(
|
||||
nextcloud_host=nextcloud_host,
|
||||
verify_ssl=get_nextcloud_ssl_verify(),
|
||||
)
|
||||
init_response = await flow_client.initiate()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}")
|
||||
return UpdateScopesResponse(
|
||||
status="error",
|
||||
message=f"Failed to start re-provisioning flow: {e}",
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Store new flow session
|
||||
await storage.store_login_flow_session(
|
||||
user_id=user_id,
|
||||
poll_token=init_response.poll_token,
|
||||
poll_endpoint=init_response.poll_endpoint,
|
||||
requested_scopes=new_scopes,
|
||||
)
|
||||
|
||||
# Present login URL
|
||||
elicitation_result = await present_login_url(ctx, init_response.login_url)
|
||||
|
||||
if elicitation_result == "declined":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return UpdateScopesResponse(
|
||||
status="declined",
|
||||
message="Scope update declined. Call nc_auth_update_scopes again to retry.",
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
success=False,
|
||||
)
|
||||
|
||||
if elicitation_result == "cancelled":
|
||||
await storage.delete_login_flow_session(user_id)
|
||||
return UpdateScopesResponse(
|
||||
status="cancelled",
|
||||
message="Scope update cancelled. Call nc_auth_update_scopes again to retry.",
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
success=False,
|
||||
)
|
||||
|
||||
message = (
|
||||
f"Scope update requires re-authentication.\n\n"
|
||||
f"Please open this URL to log in:\n{init_response.login_url}\n\n"
|
||||
f"After logging in, call nc_auth_check_status to complete."
|
||||
)
|
||||
|
||||
if elicitation_result == "accepted":
|
||||
message = (
|
||||
"Login acknowledged for scope update. "
|
||||
"Call nc_auth_check_status to verify and complete."
|
||||
)
|
||||
|
||||
return UpdateScopesResponse(
|
||||
status="login_required",
|
||||
login_url=init_response.login_url,
|
||||
message=message,
|
||||
previous_scopes=previous_scopes if previous_scopes else None,
|
||||
new_scopes=new_scopes,
|
||||
)
|
||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
CalendarEventSummary,
|
||||
ListCalendarsResponse,
|
||||
ListEventsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
UpcomingEventsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
||||
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
|
||||
raw_categories = event.get("categories", [])
|
||||
if isinstance(raw_categories, str):
|
||||
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
|
||||
else:
|
||||
categories = raw_categories
|
||||
|
||||
start = event.get("start_datetime", "")
|
||||
if not start:
|
||||
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
|
||||
|
||||
return CalendarEventSummary(
|
||||
uid=event.get("uid", ""),
|
||||
summary=event.get("title", ""),
|
||||
start=start,
|
||||
end=event.get("end_datetime"),
|
||||
all_day=event.get("all_day", False),
|
||||
location=event.get("location") or None,
|
||||
description=event.get("description") or None,
|
||||
categories=categories,
|
||||
status=event.get("status"),
|
||||
calendar_name=event.get("calendar_name"),
|
||||
calendar_display_name=event.get("calendar_display_name")
|
||||
or event.get("calendar_name"),
|
||||
)
|
||||
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool(
|
||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
end_datetime=end_datetime,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
return events[:limit]
|
||||
events = events[:limit]
|
||||
else:
|
||||
# Search in specific calendar
|
||||
events = await client.calendar.get_calendar_events(
|
||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Enrich events with calendar context for per-event mapping.
|
||||
# Note: calendar_display_name is not available here without an
|
||||
# extra list_calendars() call; the response-level calendar_name
|
||||
# already identifies the calendar for single-calendar queries.
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = client.calendar._apply_event_filters(events, filters)
|
||||
|
||||
return events
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return ListEventsResponse(
|
||||
events=summaries,
|
||||
calendar_name=None if search_all_calendars else calendar_name,
|
||||
start_date=start_date or None,
|
||||
end_date=end_date or None,
|
||||
total_found=len(summaries),
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Calendar Event",
|
||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
if calendar_name:
|
||||
# Get events from specific calendar
|
||||
return await client.calendar.get_calendar_events(
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# calendar_display_name not available without extra API call
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
else:
|
||||
# Get events from all calendars
|
||||
all_calendars = await client.calendar.list_calendars()
|
||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
for calendar in all_calendars:
|
||||
try:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
cal_events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar["name"],
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
for event in cal_events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar["display_name"]
|
||||
all_events.extend(events)
|
||||
all_events.extend(cal_events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
# Sort by start time and limit
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
events = all_events[:limit]
|
||||
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return UpcomingEventsResponse(
|
||||
events=summaries,
|
||||
days_ahead=days_ahead,
|
||||
calendar_name=calendar_name or None,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Find Availability",
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.contacts import (
|
||||
AddressBook,
|
||||
Contact,
|
||||
ContactField,
|
||||
ListAddressBooksResponse,
|
||||
ListContactsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_vcard_fields(
|
||||
raw_values: str | dict | list | None, field_type: str
|
||||
) -> list[ContactField]:
|
||||
"""Parse polymorphic vCard field data into a list of ContactField.
|
||||
|
||||
pythonvCard4 returns field values in several shapes:
|
||||
- ``str`` – plain value, e.g. ``"alice@example.com"``
|
||||
- ``dict`` – ``{'value': '...', 'type': ['HOME', 'PREF']}``
|
||||
- ``list`` – a list whose items are any of the above
|
||||
|
||||
The ``PREF`` type parameter is treated as a *preferred* flag rather than a
|
||||
label. All other type values are lowercased and joined with ``", "``.
|
||||
"""
|
||||
if raw_values is None:
|
||||
return []
|
||||
|
||||
items: list[str | dict] = (
|
||||
raw_values if isinstance(raw_values, list) else [raw_values]
|
||||
)
|
||||
|
||||
fields: list[ContactField] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict):
|
||||
value = str(item.get("value", ""))
|
||||
if not value:
|
||||
continue
|
||||
raw_types: list[str] = item.get("type") or []
|
||||
preferred = any(t.upper() == "PREF" for t in raw_types)
|
||||
labels = [t.lower() for t in raw_types if t.upper() != "PREF"]
|
||||
fields.append(
|
||||
ContactField(
|
||||
type=field_type,
|
||||
value=value,
|
||||
label=", ".join(labels) if labels else None,
|
||||
preferred=preferred,
|
||||
)
|
||||
)
|
||||
elif isinstance(item, str) and item:
|
||||
fields.append(ContactField(type=field_type, value=item))
|
||||
|
||||
return fields
|
||||
|
||||
|
||||
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||
|
||||
Maps fullname, nickname, birthday, email, and tel fields.
|
||||
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
|
||||
or lists of either – see :func:`_parse_vcard_fields`.
|
||||
"""
|
||||
contact_info = raw.get("contact", {})
|
||||
|
||||
emails = _parse_vcard_fields(contact_info.get("email"), "email")
|
||||
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
|
||||
|
||||
# Nickname goes into custom_fields (no dedicated model field)
|
||||
custom_fields: dict[str, Any] = {}
|
||||
nickname = contact_info.get("nickname")
|
||||
if nickname:
|
||||
custom_fields["nickname"] = nickname
|
||||
|
||||
return Contact(
|
||||
uid=raw["vcard_id"],
|
||||
fn=contact_info.get("fullname", ""),
|
||||
etag=raw.get("getetag"),
|
||||
birthday=contact_info.get("birthday"),
|
||||
emails=emails,
|
||||
phones=phones,
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool(
|
||||
@@ -18,10 +98,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
|
||||
"""List all addressbooks for the user."""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
addressbooks_data = await client.contacts.list_addressbooks()
|
||||
addressbooks = [
|
||||
AddressBook(
|
||||
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
|
||||
# all tools use it as a path segment: f"{carddav_path}/{name}/"
|
||||
uri=ab["name"],
|
||||
displayname=ab.get("display_name", ab["name"]),
|
||||
ctag=ab.get("getctag"),
|
||||
)
|
||||
for ab in addressbooks_data
|
||||
]
|
||||
return ListAddressBooksResponse(
|
||||
addressbooks=addressbooks, total_count=len(addressbooks)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List Contacts",
|
||||
@@ -29,10 +122,22 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
"""List all contacts in the specified addressbook."""
|
||||
async def nc_contacts_list_contacts(
|
||||
ctx: Context, *, addressbook: str
|
||||
) -> ListContactsResponse:
|
||||
"""List all contacts in the specified addressbook.
|
||||
|
||||
Args:
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts = [_raw_contact_to_model(c) for c in contacts_data]
|
||||
return ListContactsResponse(
|
||||
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Create Address Book",
|
||||
@@ -79,7 +184,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
"""Create a new contact.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook to create the contact in.
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
"""
|
||||
@@ -97,7 +204,14 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
"""Delete a contact."""
|
||||
"""Delete a contact.
|
||||
|
||||
Args:
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID of the contact to delete.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@@ -113,7 +227,9 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
"""Update an existing contact while preserving all existing properties.
|
||||
|
||||
Args:
|
||||
addressbook: The name of the addressbook containing the contact.
|
||||
addressbook: The URI slug of the addressbook (e.g. "contacts"),
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID of the contact to update.
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
|
||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
LabelOperationResponse,
|
||||
ListBoardsResponse,
|
||||
ListCardsResponse,
|
||||
ListLabelsResponse,
|
||||
ListStacksResponse,
|
||||
StackOperationResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
return [label.model_dump() for label in (board.labels or [])]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||
async def deck_label_resource(board_id: int, label_id: int):
|
||||
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Board",
|
||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Stack",
|
||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@instrument_tool
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
) -> ListCardsResponse:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
return []
|
||||
cards = stack.cards or []
|
||||
return ListCardsResponse(cards=cards, total=len(cards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Card",
|
||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
labels = board.labels or []
|
||||
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Label",
|
||||
@@ -637,7 +641,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Remove Label from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
@@ -692,7 +698,9 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
|
||||
@mcp.tool(
|
||||
title="Unassign User from Deck Card",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("deck:write")
|
||||
@instrument_tool
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user