Compare commits
443 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ff8828e972 | |||
| 43c7421d28 | |||
| a987643f8e | |||
| 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
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
fi
|
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..."
|
echo "Checking MCP server for version bump..."
|
||||||
|
|
||||||
# Get the most recent MCP tag
|
# Get the most recent MCP tag
|
||||||
@@ -83,33 +83,36 @@ jobs:
|
|||||||
commit_range="${last_mcp_tag}..HEAD"
|
commit_range="${last_mcp_tag}..HEAD"
|
||||||
fi
|
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 | \
|
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
|
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||||
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||||
echo "Bumping MCP server version..."
|
echo "Bumping MCP server version..."
|
||||||
./scripts/bump-mcp.sh
|
./scripts/bump-mcp.sh
|
||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||||
|
MCP_BUMPED=true
|
||||||
else
|
else
|
||||||
echo "No commits found for MCP server since $last_mcp_tag"
|
echo "No commits found for MCP server since $last_mcp_tag"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump Helm chart (scope: helm)
|
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
|
||||||
echo "Checking Helm chart for version bump..."
|
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
|
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||||
echo "Bumping Helm chart version..."
|
HELM_HAS_COMMITS=true
|
||||||
./scripts/bump-helm.sh
|
|
||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Bump Astrolabe (scope: astrolabe)
|
if [ "$HELM_HAS_COMMITS" = true ]; then
|
||||||
echo "Checking Astrolabe for version bump..."
|
echo "Bumping Helm chart version (helm-scoped commits)..."
|
||||||
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
|
./scripts/bump-helm.sh
|
||||||
echo "Bumping Astrolabe version..."
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
./scripts/bump-astrolabe.sh
|
elif [ "$MCP_BUMPED" = true ]; then
|
||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
|
echo "Bumping Helm chart version (appVersion changed)..."
|
||||||
|
./scripts/bump-helm.sh --increment PATCH
|
||||||
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Output summary
|
# Output summary
|
||||||
@@ -147,10 +150,6 @@ jobs:
|
|||||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
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
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,16 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
uses: anthropics/claude-code-action@ade221fd1c400376a4799977d683a4eda09f9d7c # v1.0.60
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
allowed_bots: "renovate-bot-cbcoutinho"
|
||||||
prompt: |
|
prompt: |
|
||||||
REPO: ${{ github.repository }}
|
REPO: ${{ github.repository }}
|
||||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
uses: anthropics/claude-code-action@ade221fd1c400376a4799977d683a4eda09f9d7c # v1.0.60
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
@@ -34,18 +34,18 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- 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
|
- name: Log in to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- v*
|
- v*
|
||||||
|
- nextcloud-mcp-server-*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -14,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- 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:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
./docker-compose.yml
|
./docker-compose.yml
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: rag-evaluation-results
|
name: rag-evaluation-results
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
submodules: 'true'
|
submodules: 'true'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
- name: Set up php 8.4
|
- name: Set up php 8.4
|
||||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
@@ -48,32 +48,15 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
|
|
||||||
###### Required to build Astrolabe App ######
|
|
||||||
|
|
||||||
- name: Set up Node.js for Astrolabe
|
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Build Astrolabe app
|
|
||||||
run: |
|
|
||||||
cd third_party/astrolabe
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
###### Required to build Astrolabe App ######
|
|
||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
#compose-flags: "--profile qdrant"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4,3 +4,6 @@
|
|||||||
[submodule "third_party/notes"]
|
[submodule "third_party/notes"]
|
||||||
path = third_party/notes
|
path = third_party/notes
|
||||||
url = https://github.com/cbcoutinho/notes
|
url = https://github.com/cbcoutinho/notes
|
||||||
|
[submodule "third_party/astrolabe"]
|
||||||
|
path = third_party/astrolabe
|
||||||
|
url = https://github.com/cbcoutinho/astrolabe
|
||||||
|
|||||||
+244
@@ -5,6 +5,250 @@ 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/),
|
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/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.64.4 (2026-02-26)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency icalendar to v7
|
||||||
|
|
||||||
|
## v0.64.3 (2026-02-21)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #574 fourth review round
|
||||||
|
- address PR #574 third review round
|
||||||
|
- address PR #574 second review round
|
||||||
|
- address PR #574 review comments
|
||||||
|
- wrap raw list returns in response models to produce single TextContent block
|
||||||
|
|
||||||
|
## v0.64.2 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## v0.64.1 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## v0.64.0 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 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)
|
## v0.57.0 (2025-12-20)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -239,6 +239,25 @@ uv run python -m tests.load.benchmark --output results.json --verbose
|
|||||||
|
|
||||||
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
|
**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
|
```bash
|
||||||
# Connect to database
|
# Connect to database
|
||||||
docker compose exec db mariadb -u root -ppassword nextcloud
|
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_registration_tokens` - RFC 7592 registration tokens
|
||||||
- `oc_oidc_redirect_uris` - Redirect URIs
|
- `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
|
## Architecture Quick Reference
|
||||||
|
|
||||||
**For detailed architecture, see:**
|
**For detailed architecture, see:**
|
||||||
|
|||||||
+3
-13
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Version Management
|
## 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
|
### 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` |
|
| 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` |
|
| 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
|
### Commit Message Format
|
||||||
|
|
||||||
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
|
|||||||
# Helm chart changes
|
# Helm chart changes
|
||||||
feat(helm): add resource limits
|
feat(helm): add resource limits
|
||||||
docs(helm): update values documentation
|
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:
|
**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
|
#### 1. Make Changes with Scoped Commits
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
|
||||||
git commit -m "feat(helm): add ingress annotations"
|
git commit -m "feat(helm): add ingress annotations"
|
||||||
git commit -m "feat(mcp): add calendar sync"
|
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
|
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||||
# → Updates: Chart.yaml:version
|
# → 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
|
#### 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
|
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
- **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
|
### 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)
|
- **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)
|
- **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
|
### 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:39e4e1ccb01578e3c86f7a0cf7b7fd89b8dbe2c27a88de11cf726ba669469f49
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.6@sha256:2f2ccd27bbf953ec7a9e3153a4563705e41c852a5e1912b438fc44d88d6cb52c /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+2
-2
@@ -12,12 +12,12 @@
|
|||||||
# - Per-session app password authentication
|
# - Per-session app password authentication
|
||||||
# - Multi-user support via Smithery session config
|
# - 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:39e4e1ccb01578e3c86f7a0cf7b7fd89b8dbe2c27a88de11cf726ba669469f49
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# 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.6@sha256:2f2ccd27bbf953ec7a9e3153a4563705e41c852a5e1912b438fc44d88d6cb52c /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
|
|||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
The server supports two authentication modes:
|
The server supports three authentication modes:
|
||||||
|
|
||||||
**Single-User Mode (BasicAuth):**
|
**Single-User Mode (BasicAuth):**
|
||||||
- One set of credentials shared by all MCP clients
|
- One set of credentials shared by all MCP clients
|
||||||
@@ -113,6 +113,12 @@ The server supports two authentication modes:
|
|||||||
- More secure: tokens expire, credentials never shared with server
|
- More secure: tokens expire, credentials never shared with server
|
||||||
- Best for: Teams, multi-user deployments, production environments with multiple users
|
- Best for: Teams, multi-user deployments, production environments with multiple users
|
||||||
|
|
||||||
|
**Hybrid Mode (Multi-User BasicAuth + OAuth):**
|
||||||
|
- MCP clients use BasicAuth (simple, stateless)
|
||||||
|
- Admin operations use OAuth (webhooks, background sync)
|
||||||
|
- Best for: Nextcloud deployments with admin-managed webhooks and semantic search
|
||||||
|
- Requires: `ENABLE_MULTI_USER_BASIC_AUTH=true` + `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
|
||||||
|
|
||||||
## Semantic Search
|
## Semantic Search
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ set -euox pipefail
|
|||||||
|
|
||||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
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)
|
# Set overwrite.cli.url to the external URL for OIDC discovery
|
||||||
# These ensure that URLs generated by Nextcloud include the correct host:port
|
# This ensures OAuth flows redirect to the correct external URL
|
||||||
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
|
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
|
||||||
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
|
# 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"
|
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
||||||
|
|||||||
@@ -2,35 +2,17 @@
|
|||||||
|
|
||||||
set -euox pipefail
|
set -euox pipefail
|
||||||
|
|
||||||
echo "Installing Astrolabe app for testing..."
|
echo "Installing Astrolabe app from app store..."
|
||||||
|
|
||||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
if [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||||
if [ -d /opt/apps/astrolabe ]; then
|
|
||||||
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
|
||||||
|
|
||||||
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
|
||||||
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
|
||||||
echo "Removing existing astrolabe in custom_apps..."
|
|
||||||
rm -rf /var/www/html/custom_apps/astrolabe
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink from custom_apps to the mounted development version
|
|
||||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
|
||||||
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
|
||||||
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
|
||||||
|
|
||||||
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
|
||||||
php /var/www/html/occ app:enable astrolabe
|
|
||||||
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
|
||||||
echo "astrolabe app directory found in custom_apps (already installed)"
|
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
else
|
else
|
||||||
echo "astrolabe app not found, installing from app store..."
|
|
||||||
php /var/www/html/occ app:install astrolabe
|
php /var/www/html/occ app:install astrolabe
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✓ Astrolabe app installed successfully"
|
echo "Astrolabe app installed successfully"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||||
echo " to support testing multiple MCP server deployments."
|
echo " to support testing multiple MCP server deployments."
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Configure MCP server URL for Astrolabe background sync
|
||||||
|
# This URL is used by Astrolabe to send app passwords to the MCP server
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# The MCP multi-user BasicAuth service runs on port 8000 inside the container
|
||||||
|
# From Nextcloud's perspective (inside Docker network), we reach it via service name
|
||||||
|
MCP_SERVER_URL="${MCP_SERVER_URL:-http://mcp-multi-user-basic:8000}"
|
||||||
|
|
||||||
|
echo "Configuring MCP server URL: $MCP_SERVER_URL"
|
||||||
|
|
||||||
|
# Set the mcp_server_url in config.php via occ
|
||||||
|
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
|
||||||
|
|
||||||
|
echo "MCP server URL configured successfully"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.54.0"
|
version = "0.57.85"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,394 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.85 (2026-02-25)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.84 (2026-02-25)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.83 (2026-02-25)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.82 (2026-02-25)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.81 (2026-02-25)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.80 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.79 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.78 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.77 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.76 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.75 (2026-02-23)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.74 (2026-02-21)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.73 (2026-02-21)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #574 fourth review round
|
||||||
|
- address PR #574 third review round
|
||||||
|
- address PR #574 second review round
|
||||||
|
- address PR #574 review comments
|
||||||
|
- wrap raw list returns in response models to produce single TextContent block
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.72 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.69 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.67 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.66 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.65 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.64 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.63 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.62 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.61 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.60 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.59 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.58 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.57 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.56 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.55 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.54 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.53 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.52 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.51 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add self-signed SSL certificate support for Nextcloud connections
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add type: ignore for caldav ssl_verify_cert parameter
|
||||||
|
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.50 (2026-02-16)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||||
|
|
||||||
|
### 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)
|
## nextcloud-mcp-server-0.54.0 (2025-12-19)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
version: 1.16.3
|
version: 1.17.0
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.36.0
|
version: 1.45.0
|
||||||
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
digest: sha256:a325b7093a64921fb5c6648c19c31a61799c8b279da21f08b9e892a9e5a37227
|
||||||
generated: "2025-12-22T11:09:39.166328543Z"
|
generated: "2026-02-23T05:14:08.147145912Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.54.0
|
version: 0.57.85
|
||||||
appVersion: "0.57.0"
|
appVersion: "0.64.4"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,10 +27,10 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.3"
|
version: "1.17.0"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.36.0"
|
version: "1.45.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ ingress:
|
|||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
|
||||||
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
|
| `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:**
|
**Smart Defaults:**
|
||||||
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
|
- `*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
|
#### Authentication
|
||||||
|
|
||||||
@@ -118,6 +118,25 @@ ingress:
|
|||||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
| `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
|
#### MCP Server Configuration
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
@@ -208,16 +227,16 @@ The application exposes HTTP health check endpoints:
|
|||||||
|
|
||||||
#### Vector Search & Semantic Capabilities (Optional)
|
#### 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 |
|
| Parameter | Description | Default |
|
||||||
|-----------|-------------|---------|
|
|-----------|-------------|---------|
|
||||||
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
|
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
|
||||||
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
|
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
|
||||||
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
|
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
|
||||||
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
| `semanticSearch.queueMaxSize` | Maximum queue size for pending documents | `10000` |
|
||||||
|
|
||||||
**Document Chunking Configuration:**
|
**Document Chunking Configuration:**
|
||||||
|
|
||||||
@@ -427,7 +446,7 @@ nextcloud:
|
|||||||
host: https://cloud.example.com
|
host: https://cloud.example.com
|
||||||
# mcpServerUrl and publicIssuerUrl are optional!
|
# mcpServerUrl and publicIssuerUrl are optional!
|
||||||
# If not set, mcpServerUrl defaults to ingress host or localhost
|
# 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:
|
auth:
|
||||||
mode: oauth
|
mode: oauth
|
||||||
@@ -459,7 +478,7 @@ This example shows OAuth without pre-registered credentials (using DCR) and opti
|
|||||||
nextcloud:
|
nextcloud:
|
||||||
host: https://cloud.example.com
|
host: https://cloud.example.com
|
||||||
# mcpServerUrl will automatically use ingress host (https://mcp.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:
|
auth:
|
||||||
mode: oauth
|
mode: oauth
|
||||||
@@ -537,8 +556,8 @@ auth:
|
|||||||
username: admin
|
username: admin
|
||||||
password: secure-password
|
password: secure-password
|
||||||
|
|
||||||
# Enable vector sync
|
# Enable semantic search
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
scanInterval: 1800 # Scan every 30 minutes
|
scanInterval: 1800 # Scan every 30 minutes
|
||||||
processorWorkers: 5
|
processorWorkers: 5
|
||||||
@@ -576,7 +595,7 @@ ollama:
|
|||||||
Or use an external Ollama instance:
|
Or use an external Ollama instance:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
@@ -592,7 +611,7 @@ ollama:
|
|||||||
Or use OpenAI for embeddings:
|
Or use OpenAI for embeddings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
vectorSync:
|
semanticSearch:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
@@ -689,7 +708,9 @@ Readiness (returns 200 if ready, 503 if not ready):
|
|||||||
|
|
||||||
1. **Connection refused to Nextcloud**
|
1. **Connection refused to Nextcloud**
|
||||||
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
|
- 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
|
- 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**
|
2. **Authentication failures**
|
||||||
- For basic auth: verify username/password are correct
|
- For basic auth: verify username/password are correct
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
{{- if .Values.vectorSync.enabled }}
|
{{- if .Values.semanticSearch.enabled }}
|
||||||
|
|
||||||
5. Vector Search & Semantic Capabilities:
|
5. Semantic Search & Vector Capabilities:
|
||||||
- Vector Sync: Enabled
|
- Semantic Search: Enabled
|
||||||
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
|
- Scan Interval: {{ .Values.semanticSearch.scanInterval }}s
|
||||||
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
|
- Processor Workers: {{ .Values.semanticSearch.processorWorkers }}
|
||||||
{{- if .Values.qdrant.enabled }}
|
{{- if .Values.qdrant.enabled }}
|
||||||
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
|
||||||
{{- else }}
|
{{- else }}
|
||||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
|||||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||||
|
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||||
|
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
DEPRECATION WARNING
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
You are using deprecated persistence configuration that will be removed in a
|
||||||
|
future release. Your deployment will continue to work, but please migrate to
|
||||||
|
the new unified dataStorage configuration.
|
||||||
|
|
||||||
|
Deprecated settings detected:
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||||
|
{{- end }}
|
||||||
|
{{- if $legacyQdrant }}
|
||||||
|
- qdrant.localPersistence.* (currently enabled)
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
To migrate, update your values.yaml:
|
||||||
|
|
||||||
|
dataStorage:
|
||||||
|
enabled: true
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- else if $legacyQdrant }}
|
||||||
|
size: {{ .Values.qdrant.localPersistence.size }}
|
||||||
|
{{- end }}
|
||||||
|
# storageClass: "" # Optional: specify storage class
|
||||||
|
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||||
|
|
||||||
|
After migrating, remove the deprecated settings:
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
- auth.multiUserBasic.persistence.enabled
|
||||||
|
- auth.multiUserBasic.persistence.size
|
||||||
|
- auth.multiUserBasic.persistence.storageClass
|
||||||
|
- auth.multiUserBasic.persistence.accessMode
|
||||||
|
{{- end }}
|
||||||
|
{{- if $legacyQdrant }}
|
||||||
|
- qdrant.localPersistence.enabled
|
||||||
|
- qdrant.localPersistence.size
|
||||||
|
- qdrant.localPersistence.storageClass
|
||||||
|
- qdrant.localPersistence.accessMode
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
For more information and documentation:
|
For more information and documentation:
|
||||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||||
|
|||||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the PVC to use for /app/data storage
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||||
|
{{- if .Values.dataStorage.existingClaim }}
|
||||||
|
{{- .Values.dataStorage.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Determine if data storage PVC should be enabled (backward compatible)
|
||||||
|
Checks new dataStorage.enabled OR legacy persistence configs
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||||
|
{{- if .Values.dataStorage.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Check if legacy multi-user-basic persistence config is being used
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||||
|
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Check if legacy qdrant persistence config is being used
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||||
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Return the MCP server port
|
Return the MCP server port
|
||||||
*/}}
|
*/}}
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ spec:
|
|||||||
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||||
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
|
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
|
||||||
# Background operations with app passwords
|
# Background operations with app passwords (replaces deprecated ENABLE_OFFLINE_ACCESS)
|
||||||
- name: ENABLE_OFFLINE_ACCESS
|
- name: ENABLE_BACKGROUND_OPERATIONS
|
||||||
value: "true"
|
value: "true"
|
||||||
- name: TOKEN_STORAGE_DB
|
- name: TOKEN_STORAGE_DB
|
||||||
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
|
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
|
||||||
@@ -100,7 +100,7 @@ spec:
|
|||||||
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
|
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
|
||||||
- name: NEXTCLOUD_OIDC_SCOPES
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
|
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
|
||||||
{{- if .Values.auth.multiUserBasic.clientId }}
|
{{- if or .Values.auth.multiUserBasic.clientId .Values.auth.multiUserBasic.existingSecret }}
|
||||||
# Static OAuth credentials (optional - uses DCR if not provided)
|
# Static OAuth credentials (optional - uses DCR if not provided)
|
||||||
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
@@ -122,7 +122,7 @@ spec:
|
|||||||
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
- name: NEXTCLOUD_OIDC_SCOPES
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
value: {{ .Values.auth.oauth.scopes | quote }}
|
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
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -182,16 +182,16 @@ spec:
|
|||||||
value: {{ .Values.documentProcessing.custom.types | quote }}
|
value: {{ .Values.documentProcessing.custom.types | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
# Vector Sync
|
# Semantic Search (replaces deprecated VECTOR_SYNC_ENABLED)
|
||||||
- name: VECTOR_SYNC_ENABLED
|
- name: ENABLE_SEMANTIC_SEARCH
|
||||||
value: {{ .Values.vectorSync.enabled | quote }}
|
value: {{ .Values.semanticSearch.enabled | quote }}
|
||||||
{{- if .Values.vectorSync.enabled }}
|
{{- if .Values.semanticSearch.enabled }}
|
||||||
- name: VECTOR_SYNC_SCAN_INTERVAL
|
- name: VECTOR_SYNC_SCAN_INTERVAL
|
||||||
value: {{ .Values.vectorSync.scanInterval | quote }}
|
value: {{ .Values.semanticSearch.scanInterval | quote }}
|
||||||
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
- name: VECTOR_SYNC_PROCESSOR_WORKERS
|
||||||
value: {{ .Values.vectorSync.processorWorkers | quote }}
|
value: {{ .Values.semanticSearch.processorWorkers | quote }}
|
||||||
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
|
||||||
value: {{ .Values.vectorSync.queueMaxSize | quote }}
|
value: {{ .Values.semanticSearch.queueMaxSize | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
# Document Chunking (always set, used by vector sync processor)
|
# Document Chunking (always set, used by vector sync processor)
|
||||||
- name: DOCUMENT_CHUNK_SIZE
|
- name: DOCUMENT_CHUNK_SIZE
|
||||||
@@ -286,14 +286,8 @@ spec:
|
|||||||
- name: oauth-storage
|
- name: oauth-storage
|
||||||
mountPath: /app/.oauth
|
mountPath: /app/.oauth
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
- name: data-storage
|
||||||
- name: token-storage
|
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
{{- end }}
|
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
|
||||||
- name: qdrant-data
|
|
||||||
mountPath: /app/data
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.volumeMounts }}
|
{{- with .Values.volumeMounts }}
|
||||||
{{- toYaml . | nindent 12 }}
|
{{- toYaml . | nindent 12 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -305,15 +299,12 @@ spec:
|
|||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
- name: data-storage
|
||||||
- name: token-storage
|
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||||
{{- end }}
|
{{- else }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
emptyDir: {}
|
||||||
- name: qdrant-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .Values.volumes }}
|
{{- with .Values.volumes }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -16,38 +16,34 @@ spec:
|
|||||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||||
|
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||||
|
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||||
|
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||||
|
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||||
|
{{- $size := .Values.dataStorage.size }}
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||||
|
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- else if $legacyQdrant }}
|
||||||
|
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||||
|
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||||
|
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||||
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||||
labels:
|
labels:
|
||||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
- {{ $accessMode }}
|
||||||
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
{{- if $storageClass }}
|
||||||
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
storageClassName: {{ $storageClass }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
storage: {{ $size }}
|
||||||
{{- end }}
|
|
||||||
---
|
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
|
||||||
labels:
|
|
||||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
|
||||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
|
||||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -26,9 +26,16 @@ nextcloud:
|
|||||||
# Example: https://mcp.example.com
|
# Example: https://mcp.example.com
|
||||||
mcpServerUrl: ""
|
mcpServerUrl: ""
|
||||||
|
|
||||||
# Public issuer URL for OAuth (OAuth mode only)
|
# Public issuer URL for browser-accessible OAuth authorization endpoints (OAuth mode only)
|
||||||
# If not specified, defaults to nextcloud.host
|
# ONLY used to make authorization endpoints accessible to users' browsers
|
||||||
# Only set this if your Nextcloud is accessible at a different URL for OAuth
|
# 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
|
# Example: https://cloud.example.com
|
||||||
publicIssuerUrl: ""
|
publicIssuerUrl: ""
|
||||||
|
|
||||||
@@ -132,6 +139,27 @@ auth:
|
|||||||
# Use existing PVC
|
# Use existing PVC
|
||||||
existingClaim: ""
|
existingClaim: ""
|
||||||
|
|
||||||
|
# Data Storage Configuration
|
||||||
|
# Persistent volume for /app/data directory
|
||||||
|
# Used for: token databases, qdrant persistent storage, and any app data
|
||||||
|
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||||
|
dataStorage:
|
||||||
|
# Enable persistent storage for /app/data
|
||||||
|
# Set to true when using:
|
||||||
|
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||||
|
# - Qdrant persistent mode (stores vector database)
|
||||||
|
# - Any feature requiring persistent app data
|
||||||
|
# Set to false for basic auth without persistence (uses emptyDir)
|
||||||
|
enabled: false
|
||||||
|
# Storage class (leave empty for default)
|
||||||
|
storageClass: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||||
|
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||||
|
size: 1Gi
|
||||||
|
# Use existing PVC
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
# MCP server configuration
|
# MCP server configuration
|
||||||
mcp:
|
mcp:
|
||||||
# Transport mode (default: streamable-http for SSE)
|
# Transport mode (default: streamable-http for SSE)
|
||||||
@@ -358,10 +386,11 @@ extraEnvFrom: []
|
|||||||
# - secretRef:
|
# - secretRef:
|
||||||
# name: my-secret
|
# name: my-secret
|
||||||
|
|
||||||
# Vector Sync Configuration
|
# Semantic Search Configuration
|
||||||
# Background synchronization of Nextcloud content into vector database for semantic search
|
# Enable semantic search with BM25 hybrid search and background synchronization
|
||||||
vectorSync:
|
# of Nextcloud content into vector database
|
||||||
# Enable background vector synchronization
|
semanticSearch:
|
||||||
|
# Enable semantic search and background vector synchronization
|
||||||
enabled: false
|
enabled: false
|
||||||
# Scan interval in seconds (how often to check for changes)
|
# Scan interval in seconds (how often to check for changes)
|
||||||
scanInterval: 3600
|
scanInterval: 3600
|
||||||
@@ -372,7 +401,7 @@ vectorSync:
|
|||||||
|
|
||||||
# Document Chunking Configuration
|
# Document Chunking Configuration
|
||||||
# Controls how documents are split into chunks before embedding
|
# Controls how documents are split into chunks before embedding
|
||||||
# Only relevant when vectorSync.enabled is true
|
# Only relevant when semanticSearch.enabled is true
|
||||||
documentChunking:
|
documentChunking:
|
||||||
# Number of words per chunk (default: 512)
|
# Number of words per chunk (default: 512)
|
||||||
# Smaller chunks (256-384): Better for precise searches, more chunks to store
|
# Smaller chunks (256-384): Better for precise searches, more chunks to store
|
||||||
|
|||||||
+18
-18
@@ -3,11 +3,13 @@ services:
|
|||||||
# https://hub.docker.com/_/mariadb
|
# https://hub.docker.com/_/mariadb
|
||||||
db:
|
db:
|
||||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
# 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
|
restart: always
|
||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
- db:/var/lib/mysql
|
- db:/var/lib/mysql
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:3306:3306
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=password
|
- MYSQL_ROOT_PASSWORD=password
|
||||||
- MYSQL_PASSWORD=password
|
- MYSQL_PASSWORD=password
|
||||||
@@ -17,14 +19,14 @@ services:
|
|||||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||||
# https://hub.docker.com/_/redis
|
# https://hub.docker.com/_/redis
|
||||||
redis:
|
redis:
|
||||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
image: docker.io/library/nextcloud:32.0.6@sha256:dcf9c6019d05df721bb7bada99748964c95446ea479771e9073ceaded733407e
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 127.0.0.1:8080:80
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- db
|
- db
|
||||||
@@ -35,7 +37,6 @@ services:
|
|||||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||||
# The post-installation hook will register /opt/apps as an additional app directory
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
#- ./third_party:/opt/apps:ro
|
#- ./third_party:/opt/apps:ro
|
||||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -52,14 +53,14 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
|
||||||
unstructured:
|
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
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8000
|
- 127.0.0.1:8002:8000
|
||||||
@@ -86,8 +87,8 @@ services:
|
|||||||
- NEXTCLOUD_PASSWORD=admin
|
- NEXTCLOUD_PASSWORD=admin
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Semantic search configuration (ADR-007, ADR-021)
|
||||||
#- VECTOR_SYNC_ENABLED=true
|
#- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -138,14 +139,13 @@ services:
|
|||||||
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
- ENABLE_OFFLINE_ACCESS=true
|
|
||||||
- ENABLE_BACKGROUND_OPERATIONS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
# Token storage (required for middleware initialization)
|
# Token storage (required for middleware initialization)
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -178,7 +178,7 @@ services:
|
|||||||
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
|
- 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)
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
- ENABLE_OFFLINE_ACCESS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
@@ -186,8 +186,8 @@ services:
|
|||||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Semantic search configuration (ADR-007, ADR-021)
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -255,7 +255,7 @@ services:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
|
||||||
|
|
||||||
# Refresh token storage (ADR-002 Tier 1 & 2)
|
# Refresh token storage (ADR-002 Tier 1 & 2)
|
||||||
- ENABLE_OFFLINE_ACCESS=true
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
@@ -288,13 +288,13 @@ services:
|
|||||||
- 127.0.0.1:8081:8081
|
- 127.0.0.1:8081:8081
|
||||||
environment:
|
environment:
|
||||||
- SMITHERY_DEPLOYMENT=true
|
- SMITHERY_DEPLOYMENT=true
|
||||||
- VECTOR_SYNC_ENABLED=false
|
- ENABLE_SEMANTIC_SEARCH=false
|
||||||
- PORT=8081
|
- PORT=8081
|
||||||
profiles:
|
profiles:
|
||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
|
||||||
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
|
- [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
|
## Mode Detection
|
||||||
|
|
||||||
The server automatically detects the authentication mode:
|
The server automatically detects the authentication mode:
|
||||||
|
|||||||
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## SSL/TLS Configuration (Optional)
|
||||||
|
|
||||||
|
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
|
||||||
|
|
||||||
|
### Custom CA Bundle (Recommended)
|
||||||
|
|
||||||
|
Point the server at your CA certificate file:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
With Docker, mount the certificate as a read-only volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run \
|
||||||
|
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||||
|
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
|
||||||
|
-e NEXTCLOUD_HOST=https://nextcloud.local \
|
||||||
|
--env-file .env \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Verification (Development Only)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Disabling TLS verification is insecure. Only use this for local development or testing.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_VERIFY_SSL=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
|
||||||
|
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
|
||||||
|
|
||||||
|
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
|
||||||
|
- OIDC discovery and token endpoints
|
||||||
|
- OAuth client registration (DCR)
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Semantic Search Configuration (Optional)
|
## Semantic Search Configuration (Optional)
|
||||||
|
|
||||||
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Webhook Management Guide
|
||||||
|
|
||||||
|
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
|
||||||
|
|
||||||
|
**Related ADRs:**
|
||||||
|
- ADR-010: Webhook-Based Vector Sync
|
||||||
|
- ADR-020: Deployment Modes and Configuration Validation
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Before enabling webhooks, ensure:
|
||||||
|
|
||||||
|
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||||
|
2. **[Astrolabe app](https://github.com/cbcoutinho/astrolabe)** installed in Nextcloud (provides settings UI and credentials API)
|
||||||
|
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||||
|
4. **Vector sync enabled** on the MCP server
|
||||||
|
|
||||||
|
## Webhook Architecture Overview
|
||||||
|
|
||||||
|
The webhook system has two components:
|
||||||
|
|
||||||
|
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
|
||||||
|
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
|
||||||
|
|
||||||
|
Both must be configured for webhooks to function properly.
|
||||||
|
|
||||||
|
## Deployment Mode Specifics
|
||||||
|
|
||||||
|
### 1. Single-User BasicAuth
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
1. Register webhooks using occ commands (requires Nextcloud admin):
|
||||||
|
```bash
|
||||||
|
# Enable webhook_listeners app
|
||||||
|
php occ app:enable webhook_listeners
|
||||||
|
|
||||||
|
# Register webhooks for vector sync
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8000/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
|
||||||
|
# Repeat for other events (see Event Types below)
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Optionally reduce polling frequency:
|
||||||
|
```bash
|
||||||
|
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
```bash
|
||||||
|
# List registered webhooks
|
||||||
|
php occ webhook_listeners:list
|
||||||
|
|
||||||
|
# Remove specific webhook by ID
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Notes:**
|
||||||
|
- Simplest mode - admin credentials used for all operations
|
||||||
|
- No per-user provisioning required
|
||||||
|
- Background sync runs as the configured admin user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Multi-User BasicAuth Pass-Through
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# OAuth client for Astrolabe API access
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Credential Architecture:**
|
||||||
|
This mode uses **two separate credential mechanisms**:
|
||||||
|
|
||||||
|
1. **OAuth Session** (for management API access, including webhooks):
|
||||||
|
- Obtained via browser OAuth flow (`/oauth/login`)
|
||||||
|
- Stores refresh token in MCP server's `tokens.db`
|
||||||
|
- Used for webhook registration/management APIs
|
||||||
|
|
||||||
|
2. **App Password** (for background sync):
|
||||||
|
- Generated in Nextcloud Security settings
|
||||||
|
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
|
||||||
|
- Used by background scanners to access Nextcloud APIs
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
|
||||||
|
#### Step 1: Complete OAuth Login (for Management API)
|
||||||
|
Users must authorize the MCP server to access their Nextcloud:
|
||||||
|
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
|
||||||
|
2. Click **"Authorize via OAuth"** under "Option 1"
|
||||||
|
3. Complete OAuth consent flow
|
||||||
|
4. Verify the page shows "Background Sync Access: Active"
|
||||||
|
|
||||||
|
#### Step 2: Configure App Password (for Background Sync)
|
||||||
|
Since OAuth refresh tokens have short expiry, users should also configure an app password:
|
||||||
|
|
||||||
|
1. Navigate to **Nextcloud Settings → Security**
|
||||||
|
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
|
||||||
|
3. Return to **Nextcloud Settings → Astrolabe**
|
||||||
|
4. Under "Option 2: App Password", paste the app password
|
||||||
|
5. Click **Save**
|
||||||
|
|
||||||
|
#### Step 3: Register Webhooks (Admin)
|
||||||
|
Same as Single-User BasicAuth:
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8003/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
|
||||||
|
*Per-User:*
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||||
|
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
|
||||||
|
|
||||||
|
*System-Wide:*
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Troubleshooting:**
|
||||||
|
|
||||||
|
If OAuth login fails with "Access forbidden - Your client is not authorized":
|
||||||
|
1. Check if OAuth client is registered:
|
||||||
|
```sql
|
||||||
|
SELECT id, name, client_identifier FROM oc_oidc_clients
|
||||||
|
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
|
||||||
|
```
|
||||||
|
2. Restart MCP server to trigger DCR re-registration
|
||||||
|
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
|
||||||
|
|
||||||
|
If background sync fails with "User no longer provisioned":
|
||||||
|
1. Verify app password is stored:
|
||||||
|
```sql
|
||||||
|
SELECT userid, configkey FROM oc_preferences
|
||||||
|
WHERE appid = 'astrolabe' AND userid = 'username';
|
||||||
|
```
|
||||||
|
2. Ensure user completed **both** OAuth login AND app password setup
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. OAuth Single-Audience (Default OAuth Mode)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable Webhooks:**
|
||||||
|
|
||||||
|
#### Step 1: User Provisioning
|
||||||
|
Users authorize via OAuth with `offline_access` scope:
|
||||||
|
|
||||||
|
1. MCP client initiates OAuth flow
|
||||||
|
2. User consents to requested scopes including `offline_access`
|
||||||
|
3. MCP server stores refresh token for background operations
|
||||||
|
|
||||||
|
Alternatively, via Astrolabe UI:
|
||||||
|
1. Navigate to **Nextcloud Settings → Astrolabe**
|
||||||
|
2. Click **"Authorize via OAuth"**
|
||||||
|
3. Complete consent flow
|
||||||
|
|
||||||
|
#### Step 2: Register Webhooks (Admin)
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
|
||||||
|
--uri "http://mcp-server:8001/webhooks/nextcloud" \
|
||||||
|
--method POST
|
||||||
|
```
|
||||||
|
|
||||||
|
**Disable Webhooks:**
|
||||||
|
|
||||||
|
*Per-User:*
|
||||||
|
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
|
||||||
|
- Via MCP tool: Use `revoke_nextcloud_access` if available
|
||||||
|
|
||||||
|
*System-Wide:*
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:remove <webhook-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. OAuth Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Enable/Disable Webhooks:**
|
||||||
|
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Smithery Stateless
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- Configuration from session URL params
|
||||||
|
- `VECTOR_SYNC_ENABLED=false` (required)
|
||||||
|
|
||||||
|
**Webhooks:**
|
||||||
|
**Not supported.** This mode is stateless with no persistent storage or background operations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Webhook Event Types
|
||||||
|
|
||||||
|
Register these webhook events for full vector sync coverage:
|
||||||
|
|
||||||
|
### File/Note Events
|
||||||
|
```bash
|
||||||
|
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Calendar Events
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables Events
|
||||||
|
```bash
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Webhook Authentication
|
||||||
|
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MCP Server
|
||||||
|
WEBHOOK_SECRET=<generate-random-secret>
|
||||||
|
|
||||||
|
# Nextcloud webhook registration
|
||||||
|
php occ webhook_listeners:add \
|
||||||
|
--event "..." \
|
||||||
|
--uri "$MCP_URL/webhooks/nextcloud" \
|
||||||
|
--header "Authorization: Bearer <secret>"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Token Storage
|
||||||
|
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
|
||||||
|
- Store the key securely (environment variable, secrets manager)
|
||||||
|
- Different users have isolated credential storage
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### MCP Server Logs
|
||||||
|
```bash
|
||||||
|
# Docker
|
||||||
|
docker compose logs mcp-multi-user-basic | grep -i webhook
|
||||||
|
|
||||||
|
# Key log messages
|
||||||
|
# - "Queued document from webhook: ..." - Success
|
||||||
|
# - "Webhook authentication failed" - Auth error
|
||||||
|
# - "User X no longer provisioned" - Missing credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nextcloud Logs
|
||||||
|
```bash
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | \
|
||||||
|
jq 'select(.message | contains("webhook"))' | tail
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Checks
|
||||||
|
```sql
|
||||||
|
-- Check registered webhooks
|
||||||
|
SELECT * FROM oc_webhook_listeners;
|
||||||
|
|
||||||
|
-- Check OAuth clients
|
||||||
|
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||||
|
|
||||||
|
-- Check user credentials stored by Astrolabe app
|
||||||
|
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Issues
|
||||||
|
|
||||||
|
### "Access forbidden - Your client is not authorized to connect"
|
||||||
|
**Cause:** OAuth client registration expired or not present in Nextcloud
|
||||||
|
**Fix:** Restart MCP server to trigger DCR re-registration
|
||||||
|
|
||||||
|
### "User X no longer provisioned, stopping scanner"
|
||||||
|
**Cause:** Background sync credentials missing or expired
|
||||||
|
**Fix:** User must complete credential provisioning (see mode-specific steps)
|
||||||
|
|
||||||
|
### "Failed to fetch" in browser console during OAuth
|
||||||
|
**Cause:** Network issue between browser and MCP server callback endpoint
|
||||||
|
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
|
||||||
|
|
||||||
|
### Webhooks not firing
|
||||||
|
**Causes:**
|
||||||
|
1. `webhook_listeners` app not enabled
|
||||||
|
2. Webhook not registered for the event type
|
||||||
|
3. Background job workers not running
|
||||||
|
**Fix:**
|
||||||
|
```bash
|
||||||
|
php occ app:enable webhook_listeners
|
||||||
|
php occ background:cron # or configure systemd cron
|
||||||
|
```
|
||||||
+13
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
|
|||||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
#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 =====
|
# ===== SECURITY & ADVANCED =====
|
||||||
# Cookie security (browser UI)
|
# Cookie security (browser UI)
|
||||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Add app_passwords table for multi-user BasicAuth mode
|
||||||
|
|
||||||
|
This migration adds support for storing app passwords that are provisioned
|
||||||
|
via Astrolabe's personal settings. This enables background sync in
|
||||||
|
multi-user BasicAuth mode without requiring OAuth.
|
||||||
|
|
||||||
|
Revision ID: 002
|
||||||
|
Revises: 001
|
||||||
|
Create Date: 2026-01-13 12:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "002"
|
||||||
|
down_revision = "001"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add app_passwords table for multi-user BasicAuth mode."""
|
||||||
|
|
||||||
|
# App passwords table for multi-user BasicAuth background sync
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS app_passwords (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_password BLOB NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for efficient user lookups
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
|
||||||
|
ON app_passwords(updated_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop app_passwords table."""
|
||||||
|
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
|
||||||
|
op.execute("DROP TABLE IF EXISTS app_passwords")
|
||||||
@@ -3,4 +3,74 @@
|
|||||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||||
authentication via the UnifiedTokenVerifier.
|
authentication via the UnifiedTokenVerifier.
|
||||||
|
|
||||||
|
This package is organized into modules by domain:
|
||||||
|
- management.py: Server status, user sessions, shared helpers
|
||||||
|
- passwords.py: App password provisioning for multi-user BasicAuth
|
||||||
|
- webhooks.py: Webhook registration management
|
||||||
|
- visualization.py: Search and PDF visualization endpoints
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Re-export all public functions for backward compatibility
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
__version__,
|
||||||
|
_parse_float_param,
|
||||||
|
_parse_int_param,
|
||||||
|
_sanitize_error_for_client,
|
||||||
|
_validate_query_string,
|
||||||
|
extract_bearer_token,
|
||||||
|
get_server_status,
|
||||||
|
get_user_session,
|
||||||
|
get_vector_sync_status,
|
||||||
|
revoke_user_access,
|
||||||
|
validate_token_and_get_user,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.passwords import (
|
||||||
|
delete_app_password,
|
||||||
|
get_app_password_status,
|
||||||
|
provision_app_password,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.visualization import (
|
||||||
|
get_chunk_context,
|
||||||
|
get_pdf_preview,
|
||||||
|
unified_search,
|
||||||
|
vector_search,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.api.webhooks import (
|
||||||
|
create_webhook,
|
||||||
|
delete_webhook,
|
||||||
|
get_installed_apps,
|
||||||
|
list_webhooks,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Version
|
||||||
|
"__version__",
|
||||||
|
# Shared helpers (from management.py)
|
||||||
|
"extract_bearer_token",
|
||||||
|
"validate_token_and_get_user",
|
||||||
|
"_sanitize_error_for_client",
|
||||||
|
"_parse_int_param",
|
||||||
|
"_parse_float_param",
|
||||||
|
"_validate_query_string",
|
||||||
|
# Status endpoints (from management.py)
|
||||||
|
"get_server_status",
|
||||||
|
"get_vector_sync_status",
|
||||||
|
# Session endpoints (from management.py)
|
||||||
|
"get_user_session",
|
||||||
|
"revoke_user_access",
|
||||||
|
# Password endpoints (from passwords.py)
|
||||||
|
"provision_app_password",
|
||||||
|
"get_app_password_status",
|
||||||
|
"delete_app_password",
|
||||||
|
# Webhook endpoints (from webhooks.py)
|
||||||
|
"get_installed_apps",
|
||||||
|
"list_webhooks",
|
||||||
|
"create_webhook",
|
||||||
|
"delete_webhook",
|
||||||
|
# Visualization endpoints (from visualization.py)
|
||||||
|
"unified_search",
|
||||||
|
"vector_search",
|
||||||
|
"get_chunk_context",
|
||||||
|
"get_pdf_preview",
|
||||||
|
]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,427 @@
|
|||||||
|
"""App password management API endpoints.
|
||||||
|
|
||||||
|
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
|
||||||
|
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
|
||||||
|
- Store app passwords for background sync operations
|
||||||
|
- Check app password status
|
||||||
|
- Delete stored app passwords
|
||||||
|
|
||||||
|
Authentication is via BasicAuth with the user's Nextcloud credentials.
|
||||||
|
Passwords are validated against Nextcloud before being stored.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
|
||||||
|
APP_PASSWORD_PATTERN = re.compile(
|
||||||
|
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Timeout for Nextcloud API validation requests (seconds)
|
||||||
|
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
# Rate limiting configuration for app password provisioning
|
||||||
|
# Limits: 5 attempts per user per hour
|
||||||
|
RATE_LIMIT_MAX_ATTEMPTS = 5
|
||||||
|
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
|
||||||
|
|
||||||
|
# In-memory rate limiter storage
|
||||||
|
# Structure: {user_id: [(timestamp, success), ...]}
|
||||||
|
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
|
||||||
|
"""Check if user is rate limited for app password operations.
|
||||||
|
|
||||||
|
Implements a sliding window rate limiter to prevent brute-force attacks
|
||||||
|
on the app password provisioning endpoint.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_allowed, seconds_until_retry)
|
||||||
|
- is_allowed: True if request should be allowed
|
||||||
|
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
|
||||||
|
|
||||||
|
# Clean up old attempts outside the window
|
||||||
|
_rate_limit_attempts[user_id] = [
|
||||||
|
(ts, success)
|
||||||
|
for ts, success in _rate_limit_attempts[user_id]
|
||||||
|
if ts > window_start
|
||||||
|
]
|
||||||
|
|
||||||
|
# Count recent attempts (both successful and failed)
|
||||||
|
recent_attempts = len(_rate_limit_attempts[user_id])
|
||||||
|
|
||||||
|
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
|
||||||
|
# Find when the oldest attempt in the window will expire
|
||||||
|
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
|
||||||
|
seconds_until_retry = int(
|
||||||
|
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
|
||||||
|
)
|
||||||
|
return False, max(1, seconds_until_retry)
|
||||||
|
|
||||||
|
return True, 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
|
||||||
|
"""Record an app password provisioning attempt for rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
success: Whether the attempt was successful
|
||||||
|
"""
|
||||||
|
_rate_limit_attempts[user_id].append((time.time(), success))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_basic_auth(
|
||||||
|
request: Request, path_user_id: str
|
||||||
|
) -> tuple[str, str, JSONResponse | None]:
|
||||||
|
"""Extract and validate BasicAuth credentials from request.
|
||||||
|
|
||||||
|
Validates:
|
||||||
|
1. Authorization header is present and valid BasicAuth format
|
||||||
|
2. Username in credentials matches the path user_id
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with Authorization header
|
||||||
|
path_user_id: User ID from the URL path to verify against
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (username, password, error_response)
|
||||||
|
- If successful: (username, password, None)
|
||||||
|
- If failed: ("", "", JSONResponse with error)
|
||||||
|
"""
|
||||||
|
auth_header = request.headers.get("Authorization")
|
||||||
|
|
||||||
|
if not auth_header or not auth_header.startswith("Basic "):
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Missing BasicAuth credentials"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Decode BasicAuth
|
||||||
|
encoded = auth_header.split(" ", 1)[1]
|
||||||
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
except Exception:
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid BasicAuth format"},
|
||||||
|
status_code=401,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify username matches path user_id
|
||||||
|
if username != path_user_id:
|
||||||
|
logger.warning(
|
||||||
|
f"Username mismatch in app password operation for path user {path_user_id}"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
JSONResponse(
|
||||||
|
{"success": False, "error": "Username does not match path user_id"},
|
||||||
|
status_code=403,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return username, password, None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_app_password_storage(request: Request) -> RefreshTokenStorage:
|
||||||
|
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||||
|
|
||||||
|
Checks app.state.storage first, then falls back to creating from environment.
|
||||||
|
This helper avoids repeated storage initialization logic across endpoints.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request with app state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Initialized RefreshTokenStorage instance
|
||||||
|
"""
|
||||||
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
|
if not storage:
|
||||||
|
# Multi-user BasicAuth mode may not have oauth_context
|
||||||
|
# Initialize storage from environment
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
return storage
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
|
||||||
|
|
||||||
|
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
|
||||||
|
for multi-user BasicAuth mode background sync.
|
||||||
|
|
||||||
|
The request must include BasicAuth credentials where:
|
||||||
|
- username: Nextcloud user ID (must match path user_id)
|
||||||
|
- password: The app password being provisioned
|
||||||
|
|
||||||
|
The MCP server validates the app password against Nextcloud before storing it.
|
||||||
|
This proves the user owns the password and has access to Nextcloud.
|
||||||
|
|
||||||
|
Security model:
|
||||||
|
- User identity is verified via BasicAuth against Nextcloud
|
||||||
|
- App password is encrypted before storage
|
||||||
|
- Only the user who owns the password can provision it
|
||||||
|
- Rate limited to prevent brute-force attacks
|
||||||
|
"""
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check rate limit before processing
|
||||||
|
is_allowed, retry_after = _check_rate_limit(path_user_id)
|
||||||
|
if not is_allowed:
|
||||||
|
logger.warning(
|
||||||
|
f"Rate limit exceeded for app password provisioning: {path_user_id}"
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
|
||||||
|
},
|
||||||
|
status_code=429,
|
||||||
|
headers={"Retry-After": str(retry_after)},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate app password format
|
||||||
|
if not APP_PASSWORD_PATTERN.match(app_password):
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password format"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get Nextcloud host from settings
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
if not nextcloud_host:
|
||||||
|
logger.error("NEXTCLOUD_HOST not configured")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Server not configured"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate app password against Nextcloud
|
||||||
|
try:
|
||||||
|
async with nextcloud_httpx_client(
|
||||||
|
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||||
|
) as client:
|
||||||
|
# Use OCS API to verify credentials
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, app_password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"App password validation failed for user: HTTP {response.status_code}"
|
||||||
|
)
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid app password"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the user ID from response matches
|
||||||
|
data = response.json()
|
||||||
|
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
|
||||||
|
if ocs_user_id != username:
|
||||||
|
logger.warning("User ID mismatch in OCS response")
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=False)
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "User ID mismatch"},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate app password: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the validated app password
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
await storage.store_app_password(username, app_password)
|
||||||
|
|
||||||
|
_record_rate_limit_attempt(path_user_id, success=True)
|
||||||
|
logger.info(f"Provisioned app password for user: {username}")
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password stored for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "provision_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_app_password_status(request: Request) -> JSONResponse:
|
||||||
|
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
|
||||||
|
|
||||||
|
Returns status of background sync access for multi-user BasicAuth mode.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's app password for authentication.
|
||||||
|
"""
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, _, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
app_password = await storage.get_app_password(username)
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"user_id": username,
|
||||||
|
"has_app_password": app_password is not None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_app_password(request: Request) -> JSONResponse:
|
||||||
|
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
|
||||||
|
|
||||||
|
Removes the user's app password from MCP server storage.
|
||||||
|
|
||||||
|
Requires BasicAuth with the user's credentials.
|
||||||
|
"""
|
||||||
|
# Get user_id from path
|
||||||
|
path_user_id = request.path_params.get("user_id")
|
||||||
|
if not path_user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Missing user_id in path"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract and validate BasicAuth credentials
|
||||||
|
username, password, error_response = _extract_basic_auth(request, path_user_id)
|
||||||
|
if error_response is not None:
|
||||||
|
return error_response
|
||||||
|
|
||||||
|
# Validate credentials against Nextcloud
|
||||||
|
settings = get_settings()
|
||||||
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with nextcloud_httpx_client(
|
||||||
|
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||||
|
) as client:
|
||||||
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
|
response = await client.get(
|
||||||
|
test_url,
|
||||||
|
auth=(username, password),
|
||||||
|
params={"format": "json"},
|
||||||
|
headers={"OCS-APIRequest": "true"},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Invalid credentials"},
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to validate credentials: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": "Failed to validate credentials"},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
storage = await _get_app_password_storage(request)
|
||||||
|
deleted = await storage.delete_app_password(username)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted app password for user: {username}")
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"App password deleted for {username}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "No app password found to delete",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = _sanitize_error_for_client(e, "delete_app_password")
|
||||||
|
return JSONResponse(
|
||||||
|
{"success": False, "error": error_msg},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
+418
-223
File diff suppressed because it is too large
Load Diff
@@ -9,7 +9,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
|||||||
# Discover token endpoint
|
# Discover token endpoint
|
||||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
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}")
|
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||||
discovery_resp = await client.get(discovery_url)
|
discovery_resp = await client.get(discovery_url)
|
||||||
discovery_resp.raise_for_status()
|
discovery_resp.raise_for_status()
|
||||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
|||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
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}")
|
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
@@ -21,6 +23,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
|||||||
_query_idp_userinfo,
|
_query_idp_userinfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -141,7 +145,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint
|
# 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 = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -150,8 +154,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# Replace internal Docker hostname with public URL
|
# Replace internal Docker hostname with public URL
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
@@ -285,7 +287,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = 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(
|
response = await http_client.post(
|
||||||
oauth_client.token_endpoint,
|
oauth_client.token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
@@ -295,31 +297,12 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
else:
|
else:
|
||||||
# Integrated mode (Nextcloud OIDC)
|
# Integrated mode (Nextcloud OIDC)
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
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 = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
token_endpoint = discovery["token_endpoint"]
|
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 = {
|
token_params = {
|
||||||
"grant_type": "authorization_code",
|
"grant_type": "authorization_code",
|
||||||
"code": code,
|
"code": code,
|
||||||
@@ -332,7 +315,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = 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(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
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_in = token_data.get("refresh_expires_in")
|
||||||
refresh_expires_at = None
|
refresh_expires_at = None
|
||||||
if refresh_expires_in:
|
if refresh_expires_in:
|
||||||
import time
|
|
||||||
|
|
||||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
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 nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ async def register_client(
|
|||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
registration_endpoint,
|
registration_endpoint,
|
||||||
@@ -229,7 +231,7 @@ async def delete_client(
|
|||||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
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):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# Prefer RFC 7592 Bearer token authentication
|
# Prefer RFC 7592 Bearer token authentication
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -161,8 +162,6 @@ class ClientRegistry:
|
|||||||
True if valid, False otherwise
|
True if valid, False otherwise
|
||||||
"""
|
"""
|
||||||
# Parse the redirect URI
|
# Parse the redirect URI
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
parsed = urlparse(redirect_uri)
|
parsed = urlparse(redirect_uri)
|
||||||
|
|
||||||
# Check against registered patterns
|
# Check against registered patterns
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Handles OAuth flows with Keycloak as the identity provider, including:
|
|||||||
- Integration with RefreshTokenStorage
|
- Integration with RefreshTokenStorage
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -17,6 +18,8 @@ from urllib.parse import urlencode, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,7 +109,7 @@ class KeycloakOAuthClient:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client"""
|
"""Get or create HTTP client"""
|
||||||
if self._http_client is None:
|
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
|
return self._http_client
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
@@ -155,7 +158,6 @@ class KeycloakOAuthClient:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (code_verifier, code_challenge)
|
Tuple of (code_verifier, code_challenge)
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
|
|
||||||
# Generate code verifier (43-128 characters)
|
# Generate code verifier (43-128 characters)
|
||||||
code_verifier = secrets.token_urlsafe(32)
|
code_verifier = secrets.token_urlsafe(32)
|
||||||
|
|||||||
@@ -23,17 +23,21 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -217,7 +221,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint from discovery
|
# Fetch authorization endpoint from discovery
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -226,8 +230,6 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
# Parse internal and authorization endpoint to compare hostnames
|
# Parse internal and authorization endpoint to compare hostnames
|
||||||
@@ -353,7 +355,7 @@ async def oauth_authorize_nextcloud(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -362,8 +364,6 @@ async def oauth_authorize_nextcloud(
|
|||||||
# Fix internal hostname for browser access
|
# Fix internal hostname for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
@@ -461,7 +461,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
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 = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -481,7 +481,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
@@ -521,8 +521,6 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||||
refresh_expires_at = None
|
refresh_expires_at = None
|
||||||
if refresh_expires_in:
|
if refresh_expires_in:
|
||||||
import time
|
|
||||||
|
|
||||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
||||||
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
||||||
@@ -567,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
return HTMLResponse(content=success_html, status_code=200)
|
return HTMLResponse(content=success_html, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@@ -633,10 +629,6 @@ async def oauth_callback(request: Request):
|
|||||||
elif flow_type == "browser":
|
elif flow_type == "browser":
|
||||||
# Browser UI Login - establish browser session for /user/page access
|
# Browser UI Login - establish browser session for /user/page access
|
||||||
logger.info("Routing to browser login flow")
|
logger.info("Routing to browser login flow")
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|
||||||
oauth_login_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await oauth_login_callback(request)
|
return await oauth_login_callback(request)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -9,11 +9,13 @@ import functools
|
|||||||
import logging
|
import logging
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
|
import jwt
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# 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)
|
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||||
# Token exchange mode - per-request exchange, no provisioning needed
|
# Token exchange mode - per-request exchange, no provisioning needed
|
||||||
@@ -78,8 +78,6 @@ def require_provisioning(func: Callable) -> Callable:
|
|||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||||
try:
|
try:
|
||||||
import jwt
|
|
||||||
|
|
||||||
token = ctx.authorization.token
|
token = ctx.authorization.token
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
@@ -163,8 +161,6 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
|
|||||||
# Get user_id from authorization token
|
# Get user_id from authorization token
|
||||||
user_id = None
|
user_id = None
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
if hasattr(ctx, "authorization") and ctx.authorization:
|
||||||
import jwt
|
|
||||||
|
|
||||||
token = ctx.authorization.token
|
token = ctx.authorization.token
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub")
|
user_id = payload.get("sub")
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Scope-based authorization for MCP tools."""
|
"""Scope-based authorization for MCP tools."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
@@ -10,6 +9,8 @@ from mcp.server.auth.provider import AccessToken
|
|||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -131,9 +132,10 @@ def require_scopes(*required_scopes: str):
|
|||||||
required_scopes_set = set(required_scopes)
|
required_scopes_set = set(required_scopes)
|
||||||
|
|
||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
enable_offline_access = (
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
# 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
|
# In offline access mode, check if Nextcloud scopes require provisioning
|
||||||
if enable_offline_access:
|
if enable_offline_access:
|
||||||
|
|||||||
@@ -28,13 +28,18 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
from anyio import to_thread
|
||||||
from cryptography.fernet import Fernet
|
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
|
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -163,10 +168,6 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
# Run migrations in a worker thread using anyio.to_thread
|
# Run migrations in a worker thread using anyio.to_thread
|
||||||
# This allows Alembic to run its own async operations in a separate context
|
# 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 not has_alembic:
|
||||||
if has_schema:
|
if has_schema:
|
||||||
# Stamp existing database without running migrations
|
# Stamp existing database without running migrations
|
||||||
@@ -830,7 +831,6 @@ class RefreshTokenStorage:
|
|||||||
resource_id: Resource identifier
|
resource_id: Resource identifier
|
||||||
auth_method: Authentication method used
|
auth_method: Authentication method used
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
hostname = socket.gethostname()
|
hostname = socket.gethostname()
|
||||||
timestamp = int(time.time())
|
timestamp = int(time.time())
|
||||||
@@ -1240,6 +1240,243 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# App Password Storage (multi-user BasicAuth mode)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def store_app_password(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
app_password: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Store encrypted app password for background sync (multi-user BasicAuth mode).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
app_password: Nextcloud app password to store
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password storage."
|
||||||
|
)
|
||||||
|
|
||||||
|
encrypted_password = self.cipher.encrypt(app_password.encode())
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR REPLACE INTO app_passwords
|
||||||
|
(user_id, encrypted_password, created_at, updated_at)
|
||||||
|
VALUES (
|
||||||
|
?,
|
||||||
|
?,
|
||||||
|
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(user_id, encrypted_password, user_id, now, now),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "success")
|
||||||
|
logger.info(f"Stored app password for user {user_id}")
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "insert", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Audit log
|
||||||
|
await self._audit_log(
|
||||||
|
event="store_app_password",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_app_password(self, user_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve and decrypt app password for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted app password, or None if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
if not self.cipher:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Encryption key not configured. "
|
||||||
|
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
logger.debug(f"No app password found for user {user_id}")
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encrypted_password = row[0]
|
||||||
|
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "success")
|
||||||
|
logger.debug(f"Retrieved app password for user {user_id}")
|
||||||
|
|
||||||
|
return decrypted_password
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "select", duration, "error")
|
||||||
|
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def delete_app_password(self, user_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete app password for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if password was deleted, False if not found
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
try:
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"DELETE FROM app_passwords WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
deleted = cursor.rowcount > 0
|
||||||
|
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "success")
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
logger.info(f"Deleted app password for user {user_id}")
|
||||||
|
await self._audit_log(
|
||||||
|
event="delete_app_password",
|
||||||
|
user_id=user_id,
|
||||||
|
auth_method="app_password",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No app password to delete for user {user_id}")
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
duration = time.time() - start_time
|
||||||
|
record_db_operation("sqlite", "delete", duration, "error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_all_app_password_user_ids(self) -> list[str]:
|
||||||
|
"""
|
||||||
|
Get list of all user IDs with stored app passwords.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
|
async with db.execute(
|
||||||
|
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
|
||||||
|
) as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
|
||||||
|
user_ids = [row[0] for row in rows]
|
||||||
|
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||||
|
return user_ids
|
||||||
|
|
||||||
|
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Validate stored app passwords against Nextcloud and remove invalid ones.
|
||||||
|
|
||||||
|
Makes a lightweight OCS request for each stored user to check if credentials
|
||||||
|
are still valid. Removes entries that return 401/403.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs whose app passwords were removed
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
user_ids = await self.get_all_app_password_user_ids()
|
||||||
|
if not user_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
removed: list[str] = []
|
||||||
|
|
||||||
|
async def _validate_user(user_id: str) -> None:
|
||||||
|
app_password = await self.get_app_password(user_id)
|
||||||
|
if not app_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
auth=httpx.BasicAuth(user_id, app_password),
|
||||||
|
timeout=10.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/ocs/v2.php/cloud/user",
|
||||||
|
headers={
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
logger.info(
|
||||||
|
f"App password for {user_id} is invalid "
|
||||||
|
f"(HTTP {response.status_code}), removing"
|
||||||
|
)
|
||||||
|
await self.delete_app_password(user_id)
|
||||||
|
removed.append(user_id)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"App password for {user_id} validated "
|
||||||
|
f"(HTTP {response.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not validate app password for {user_id}: {e}")
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for user_id in user_ids:
|
||||||
|
tg.start_soon(_validate_user, user_id)
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
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.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client."""
|
"""Get or create HTTP client."""
|
||||||
if self._http_client is None:
|
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
|
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||||
)
|
)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
@@ -168,37 +170,6 @@ class TokenBrokerService:
|
|||||||
self._oidc_config = response.json()
|
self._oidc_config = response.json()
|
||||||
return self._oidc_config
|
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]:
|
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Get a valid Nextcloud access token for the user.
|
Get a valid Nextcloud access token for the user.
|
||||||
@@ -407,7 +378,7 @@ class TokenBrokerService:
|
|||||||
Tuple of (access_token, expires_in_seconds)
|
Tuple of (access_token, expires_in_seconds)
|
||||||
"""
|
"""
|
||||||
config = await self._get_oidc_config()
|
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()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
@@ -477,7 +448,7 @@ class TokenBrokerService:
|
|||||||
Tuple of (access_token, expires_in_seconds)
|
Tuple of (access_token, expires_in_seconds)
|
||||||
"""
|
"""
|
||||||
config = await self._get_oidc_config()
|
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()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
from .storage import RefreshTokenStorage
|
from .storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
|||||||
self.storage: Optional[RefreshTokenStorage] = None
|
self.storage: Optional[RefreshTokenStorage] = None
|
||||||
|
|
||||||
# Create HTTP client
|
# Create HTTP client
|
||||||
self.http_client = httpx.AsyncClient(
|
self.http_client = nextcloud_httpx_client(
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
record_oauth_token_validation,
|
record_oauth_token_validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||||
|
|
||||||
# Common components for all modes
|
# 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
|
# JWT verification support
|
||||||
self.jwks_client: PyJWKClient | None = None
|
self.jwks_client: PyJWKClient | None = None
|
||||||
@@ -117,6 +119,71 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
# Both modes do the same validation (MCP audience only)
|
# Both modes do the same validation (MCP audience only)
|
||||||
return await self._verify_mcp_audience(token)
|
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:
|
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Validate token has MCP audience.
|
Validate token has MCP audience.
|
||||||
@@ -186,6 +253,78 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
record_oauth_token_validation(validation_method, "error")
|
record_oauth_token_validation(validation_method, "error")
|
||||||
return None
|
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:
|
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if token has MCP audience.
|
Check if token has MCP audience.
|
||||||
@@ -230,12 +369,15 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
"""
|
"""
|
||||||
return "." in token and token.count(".") == 2
|
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.
|
Verify JWT token with signature validation using JWKS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT token to verify
|
token: JWT token to verify
|
||||||
|
skip_issuer_check: If True, skip issuer validation (for management API tokens)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded payload if valid, None if invalid
|
Decoded payload if valid, None if invalid
|
||||||
@@ -248,25 +390,22 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
# Verify and decode JWT
|
# Verify and decode JWT
|
||||||
# Note: We don't validate audience here - that's done separately based on mode
|
# 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(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
issuer=(
|
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
|
||||||
self.settings.oidc_issuer
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
options={
|
options={
|
||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_iat": True,
|
"verify_iat": True,
|
||||||
"verify_iss": (
|
"verify_iss": should_verify_issuer,
|
||||||
True
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
and self.settings.oidc_issuer
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
"verify_aud": False, # We handle audience validation separately
|
"verify_aud": False, # We handle audience validation separately
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -358,6 +497,24 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
token: The bearer token
|
token: The bearer token
|
||||||
payload: Validated token payload
|
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:
|
Returns:
|
||||||
AccessToken object or None if required fields missing
|
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")
|
logger.warning("No 'exp' claim in token, using default TTL")
|
||||||
exp = int(time.time() + self.cache_ttl)
|
exp = int(time.time() + self.cache_ttl)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result with the provided key
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"scope": scope_string,
|
"scope": scope_string,
|
||||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
**{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(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
|
|||||||
@@ -9,16 +9,21 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
from httpx import BasicAuth
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
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.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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]):
|
if not all([nextcloud_host, username, password]):
|
||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
from httpx import BasicAuth
|
|
||||||
|
|
||||||
assert nextcloud_host is not None
|
assert nextcloud_host is not None
|
||||||
assert username is not None
|
assert username is not None
|
||||||
assert password 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"
|
"status": str, # "syncing" or "idle"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
# Check if vector sync is enabled
|
# Check if vector sync is enabled (supports both old and new env var names)
|
||||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
settings = get_settings()
|
||||||
if not vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -126,10 +129,10 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
get_qdrant_client,
|
||||||
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection
|
# Count documents in collection
|
||||||
@@ -257,7 +260,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
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 = await client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -290,7 +293,7 @@ async def _query_idp_userinfo(
|
|||||||
User info dictionary from IdP, or None if query fails
|
User info dictionary from IdP, or None if query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
userinfo_uri,
|
userinfo_uri,
|
||||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||||
@@ -385,8 +388,6 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
|
|||||||
return user_context
|
return user_context
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
import traceback
|
|
||||||
|
|
||||||
logger.error(f"Error retrieving user info: {e}")
|
logger.error(f"Error retrieving user info: {e}")
|
||||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||||
return {
|
return {
|
||||||
@@ -432,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Check if user is admin (for Webhooks tab)
|
# Check if user is admin (for Webhooks tab)
|
||||||
is_admin = False
|
is_admin = False
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# Get authenticated Nextcloud client
|
||||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
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)
|
# Get Nextcloud host for generating links to apps (used by viz tab)
|
||||||
# Use public issuer URL if available (for browser-accessible links),
|
# Use public issuer URL if available (for browser-accessible links),
|
||||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
nextcloud_host_for_links = (
|
nextcloud_host_for_links = (
|
||||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
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)
|
# 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
|
# Render template
|
||||||
template = _jinja_env.get_template("user_info.html")
|
template = _jinja_env.get_template("user_info.html")
|
||||||
|
|||||||
@@ -15,18 +15,25 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
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.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.observability.tracing import trace_operation
|
||||||
from nextcloud_mcp_server.search import (
|
from nextcloud_mcp_server.search import (
|
||||||
BM25HybridSearchAlgorithm,
|
BM25HybridSearchAlgorithm,
|
||||||
SemanticSearchAlgorithm,
|
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.pca import PCA
|
||||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
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.qdrant_client import get_qdrant_client
|
||||||
@@ -136,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
# Get authenticated HTTP client from session
|
# Get authenticated HTTP client from session
|
||||||
# In BasicAuth mode: uses username/password from session
|
# In BasicAuth mode: uses username/password from session
|
||||||
# In OAuth mode: uses access token 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"):
|
with trace_operation("vector_viz.get_auth_client"):
|
||||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||||
|
|
||||||
@@ -352,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: generate embedding if not available from search
|
# Fallback: generate embedding if not available from search
|
||||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
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)
|
coords = pca.fit_transform(vectors)
|
||||||
return coords, pca
|
return coords, pca
|
||||||
|
|
||||||
import anyio
|
|
||||||
|
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_viz.pca_compute",
|
"vector_viz.pca_compute",
|
||||||
attributes={
|
attributes={
|
||||||
@@ -556,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
doc_id_int = int(doc_id)
|
doc_id_int = int(doc_id)
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# 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
|
# Use context expansion module to fetch chunk with surrounding context
|
||||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||||
chunk_context = await get_chunk_with_context(
|
chunk_context = await get_chunk_with_context(
|
||||||
@@ -595,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
page_number = None
|
page_number = None
|
||||||
if doc_type == "file":
|
if doc_type == "file":
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
username = request.user.display_name
|
username = request.user.display_name
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
|||||||
get_preset,
|
get_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 nextcloud_host is not None # Type narrowing for type checker
|
||||||
assert username is not None and password is not None # Type narrowing
|
assert username is not None and password is not None # Type narrowing
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
raise RuntimeError("Nextcloud host not configured")
|
raise RuntimeError("Nextcloud host not configured")
|
||||||
|
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import uvicorn
|
|||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
get_settings,
|
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 nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||||
|
|
||||||
from .app import get_app
|
from .app import get_app
|
||||||
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
|
|||||||
# Use custom database path
|
# Use custom database path
|
||||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import upgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Upgrading database to revision: {revision}")
|
click.echo(f"Upgrading database to revision: {revision}")
|
||||||
upgrade_database(database_path, revision)
|
upgrade_database(database_path, revision)
|
||||||
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
|
|||||||
# Downgrade to base (empty database)
|
# Downgrade to base (empty database)
|
||||||
$ nextcloud-mcp-server db downgrade --revision base
|
$ nextcloud-mcp-server db downgrade --revision base
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import downgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Downgrading database to revision: {revision}")
|
click.echo(f"Downgrading database to revision: {revision}")
|
||||||
downgrade_database(database_path, revision)
|
downgrade_database(database_path, revision)
|
||||||
@@ -362,8 +365,6 @@ def current(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db current
|
$ nextcloud-mcp-server db current
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import get_current_revision
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
revision = get_current_revision(database_path)
|
revision = get_current_revision(database_path)
|
||||||
if revision:
|
if revision:
|
||||||
@@ -397,8 +398,6 @@ def history(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db history
|
$ nextcloud-mcp-server db history
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import show_migration_history
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo("Migration history:")
|
click.echo("Migration history:")
|
||||||
show_migration_history(database_path)
|
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.
|
Note: You must manually edit the generated migration file to add SQL statements.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import create_migration
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Creating new migration: {message}")
|
click.echo(f"Creating new migration: {message}")
|
||||||
create_migration(message)
|
create_migration(message)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
from httpx import (
|
from httpx import (
|
||||||
AsyncBaseTransport,
|
AsyncBaseTransport,
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncHTTPTransport,
|
|
||||||
Auth,
|
Auth,
|
||||||
BasicAuth,
|
BasicAuth,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,6 +12,7 @@ from httpx import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..controllers.notes_search import NotesSearchController
|
from ..controllers.notes_search import NotesSearchController
|
||||||
|
from ..http import nextcloud_httpx_transport
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
from .contacts import ContactsClient
|
from .contacts import ContactsClient
|
||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
|||||||
self._client = AsyncClient(
|
self._client = AsyncClient(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||||
event_hooks={"request": [log_request], "response": [log_response]},
|
event_hooks={"request": [log_request], "response": [log_response]},
|
||||||
timeout=Timeout(timeout=30, connect=5),
|
timeout=Timeout(timeout=30, connect=5),
|
||||||
)
|
)
|
||||||
@@ -113,7 +113,7 @@ class NextcloudClient:
|
|||||||
Returns:
|
Returns:
|
||||||
NextcloudClient configured with bearer token authentication
|
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")
|
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||||
return cls(base_url=base_url, username=username, auth=BearerAuth(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
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from caldav.async_collection import AsyncCalendar
|
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||||
from caldav.async_davclient import AsyncDAVClient
|
from caldav.async_davclient import AsyncDAVClient
|
||||||
|
from caldav.elements import cdav, dav
|
||||||
from httpx import Auth
|
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 Event as ICalEvent
|
||||||
from icalendar import Todo as ICalTodo
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ class CalendarClient:
|
|||||||
url=f"{base_url}/remote.php/dav/",
|
url=f"{base_url}/remote.php/dav/",
|
||||||
username=username,
|
username=username,
|
||||||
auth=auth,
|
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}/"
|
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.
|
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
# 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"?>
|
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:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
<d:prop>
|
<d:prop>
|
||||||
@@ -255,18 +258,35 @@ class CalendarClient:
|
|||||||
"""List events in a calendar within date range."""
|
"""List events in a calendar within date range."""
|
||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
# Get all events using caldav library (now with proper filter)
|
if start_datetime or end_datetime:
|
||||||
events = await calendar.events()
|
# 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 = []
|
result = []
|
||||||
for event in events:
|
for event in events:
|
||||||
await event.load(only_if_unloaded=True)
|
await event.load(only_if_unloaded=True)
|
||||||
if event.data:
|
if event.data:
|
||||||
event_dict = self._parse_ical_event(event.data)
|
if expanded:
|
||||||
if event_dict:
|
# Server-side expansion: each response resource may contain
|
||||||
event_dict["href"] = str(event.url)
|
# multiple VEVENTs (one per recurrence occurrence)
|
||||||
event_dict["etag"] = ""
|
for event_dict in self._parse_all_ical_events(event.data):
|
||||||
result.append(event_dict)
|
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:
|
if len(result) >= limit:
|
||||||
break
|
break
|
||||||
@@ -274,6 +294,53 @@ class CalendarClient:
|
|||||||
logger.debug(f"Found {len(result)} events")
|
logger.debug(f"Found {len(result)} events")
|
||||||
return result
|
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(
|
async def create_event(
|
||||||
self, calendar_name: str, event_data: Dict[str, Any]
|
self, calendar_name: str, event_data: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -583,7 +650,7 @@ class CalendarClient:
|
|||||||
# Add categories
|
# Add categories
|
||||||
categories = event_data.get("categories", "")
|
categories = event_data.get("categories", "")
|
||||||
if categories:
|
if categories:
|
||||||
event.add("categories", categories.split(","))
|
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||||
|
|
||||||
# Add priority and status
|
# Add priority and status
|
||||||
priority = event_data.get("priority", 5)
|
priority = event_data.get("priority", 5)
|
||||||
@@ -633,75 +700,92 @@ class CalendarClient:
|
|||||||
cal.add_component(event)
|
cal.add_component(event)
|
||||||
return cal.to_ical().decode("utf-8")
|
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]]:
|
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:
|
try:
|
||||||
cal = Calendar.from_ical(ical_text)
|
cal = Calendar.from_ical(ical_text)
|
||||||
for component in cal.walk():
|
for component in cal.walk():
|
||||||
if component.name == "VEVENT":
|
if component.name == "VEVENT":
|
||||||
event_data = {
|
return self._extract_vevent_data(component)
|
||||||
"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 None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing iCalendar event: {e}")
|
logger.error(f"Error parsing iCalendar event: {e}")
|
||||||
return None
|
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(
|
def _merge_ical_properties(
|
||||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -727,6 +811,50 @@ class CalendarClient:
|
|||||||
if "url" in event_data:
|
if "url" in event_data:
|
||||||
component["URL"] = event_data["url"]
|
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
|
# Handle dates
|
||||||
if "start_datetime" in event_data:
|
if "start_datetime" in event_data:
|
||||||
start_str = event_data["start_datetime"]
|
start_str = event_data["start_datetime"]
|
||||||
@@ -757,8 +885,6 @@ class CalendarClient:
|
|||||||
component["DTEND"] = end_dt
|
component["DTEND"] = end_dt
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
now = dt.datetime.now(dt.UTC)
|
now = dt.datetime.now(dt.UTC)
|
||||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||||
component["DTSTAMP"] = vDDDTypes(now)
|
component["DTSTAMP"] = vDDDTypes(now)
|
||||||
@@ -823,24 +949,18 @@ class CalendarClient:
|
|||||||
# Due date
|
# Due date
|
||||||
due = todo_data.get("due", "")
|
due = todo_data.get("due", "")
|
||||||
if due:
|
if due:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
due_dt = self._ensure_timezone_aware(due)
|
due_dt = self._ensure_timezone_aware(due)
|
||||||
todo.add("due", vDDDTypes(due_dt))
|
todo.add("due", vDDDTypes(due_dt))
|
||||||
|
|
||||||
# Start date
|
# Start date
|
||||||
dtstart = todo_data.get("dtstart", "")
|
dtstart = todo_data.get("dtstart", "")
|
||||||
if dtstart:
|
if dtstart:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
start_dt = self._ensure_timezone_aware(dtstart)
|
start_dt = self._ensure_timezone_aware(dtstart)
|
||||||
todo.add("dtstart", vDDDTypes(start_dt))
|
todo.add("dtstart", vDDDTypes(start_dt))
|
||||||
|
|
||||||
# Completed timestamp
|
# Completed timestamp
|
||||||
completed = todo_data.get("completed", "")
|
completed = todo_data.get("completed", "")
|
||||||
if completed:
|
if completed:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
completed_dt = self._ensure_timezone_aware(completed)
|
completed_dt = self._ensure_timezone_aware(completed)
|
||||||
todo.add("completed", vDDDTypes(completed_dt))
|
todo.add("completed", vDDDTypes(completed_dt))
|
||||||
|
|
||||||
@@ -929,9 +1049,6 @@ class CalendarClient:
|
|||||||
component["PERCENT-COMPLETE"] = percent_value
|
component["PERCENT-COMPLETE"] = percent_value
|
||||||
logger.debug(f"Set PERCENT-COMPLETE to {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
|
# Handle due date
|
||||||
if "due" in todo_data:
|
if "due" in todo_data:
|
||||||
due_str = todo_data["due"]
|
due_str = todo_data["due"]
|
||||||
@@ -960,7 +1077,9 @@ class CalendarClient:
|
|||||||
if "categories" in todo_data:
|
if "categories" in todo_data:
|
||||||
categories_str = todo_data["categories"]
|
categories_str = todo_data["categories"]
|
||||||
if categories_str:
|
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}")
|
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from httpx import Timeout
|
from httpx import Timeout
|
||||||
|
|
||||||
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of matching recipe stubs
|
List of matching recipe stubs
|
||||||
"""
|
"""
|
||||||
# URL encode the query
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_query = quote(query)
|
encoded_query = quote(query)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||||
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs in the category
|
List of recipe stubs in the category
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_category = quote(category)
|
encoded_category = quote(category)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||||
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
New category name
|
New category name
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_old_name = quote(old_name)
|
encoded_old_name = quote(old_name)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs matching the keywords
|
List of recipe stubs matching the keywords
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
# Join keywords with commas
|
# Join keywords with commas
|
||||||
keywords_str = ",".join(keywords)
|
keywords_str = ",".join(keywords)
|
||||||
encoded_keywords = quote(keywords_str)
|
encoded_keywords = quote(keywords_str)
|
||||||
|
|||||||
@@ -285,28 +285,23 @@ class DeckClient(BaseNextcloudClient):
|
|||||||
archived: Optional[bool] = None,
|
archived: Optional[bool] = None,
|
||||||
done: Optional[str] = None,
|
done: Optional[str] = None,
|
||||||
) -> 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)
|
current_card = await self.get_card(board_id, stack_id, card_id)
|
||||||
|
|
||||||
json_data = {}
|
# Build payload with required fields always included
|
||||||
if title is not None:
|
json_data = {
|
||||||
json_data["title"] = title
|
# Title is required by the API
|
||||||
if description is not None:
|
"title": title if title is not None else current_card.title,
|
||||||
json_data["description"] = description
|
# Type is required by the API
|
||||||
# Type is required by the API, use provided or keep current
|
"type": type if type is not None else current_card.type,
|
||||||
json_data["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 is required by the API, use provided or keep current
|
"owner": owner if owner is not None else current_card.owner,
|
||||||
json_data["owner"] = (
|
# Description must be sent to preserve it (PUT clears omitted fields)
|
||||||
owner
|
"description": description
|
||||||
if owner is not None
|
if description is not None
|
||||||
else (
|
else (current_card.description or ""),
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if order is not None:
|
if order is not None:
|
||||||
json_data["order"] = order
|
json_data["order"] = order
|
||||||
if duedate is not None:
|
if duedate is not None:
|
||||||
@@ -391,11 +386,17 @@ class DeckClient(BaseNextcloudClient):
|
|||||||
order: int,
|
order: int,
|
||||||
target_stack_id: int,
|
target_stack_id: int,
|
||||||
) -> None:
|
) -> 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}
|
json_data = {"order": order, "stackId": target_stack_id}
|
||||||
|
headers = self._get_deck_headers()
|
||||||
await self._make_request(
|
await self._make_request(
|
||||||
"PUT",
|
"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,
|
json=json_data,
|
||||||
|
headers=headers,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Labels
|
# Labels
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import Any, AsyncIterator, Dict, Optional
|
from typing import Any, AsyncIterator, Dict, Optional
|
||||||
|
|
||||||
from .base import BaseNextcloudClient
|
from .base import BaseNextcloudClient
|
||||||
|
from .webdav import WebDAVClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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"
|
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Import here to avoid circular imports
|
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
await webdav_client.cleanup_old_attachment_directory(
|
await webdav_client.cleanup_old_attachment_directory(
|
||||||
note_id=note_id, old_category=old_note.get("category", "")
|
note_id=note_id, old_category=old_note.get("category", "")
|
||||||
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
# Clean up attachment directories
|
# Clean up attachment directories
|
||||||
try:
|
try:
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
|
|
||||||
for cat in potential_categories:
|
for cat in potential_categories:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Decode href path and extract the file path
|
# Decode href path and extract the file path
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
href_path = unquote(href_elem.text)
|
href_path = unquote(href_elem.text)
|
||||||
# Remove WebDAV prefix to get user-relative path
|
# Remove WebDAV prefix to get user-relative path
|
||||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||||
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
# Parse last modified timestamp
|
# Parse last modified timestamp
|
||||||
last_modified_timestamp = None
|
last_modified_timestamp = None
|
||||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||||
last_modified_timestamp = int(dt.timestamp())
|
last_modified_timestamp = int(dt.timestamp())
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import logging
|
import logging
|
||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -180,6 +182,10 @@ class Settings:
|
|||||||
nextcloud_username: Optional[str] = None
|
nextcloud_username: Optional[str] = None
|
||||||
nextcloud_password: Optional[str] = None
|
nextcloud_password: Optional[str] = None
|
||||||
|
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl: bool = True
|
||||||
|
nextcloud_ca_bundle: Optional[str] = None
|
||||||
|
|
||||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||||
@@ -251,9 +257,23 @@ class Settings:
|
|||||||
log_include_trace_context: bool = True
|
log_include_trace_context: bool = True
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Validate Qdrant configuration and set defaults."""
|
"""Validate configuration and set defaults."""
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Ensure mutual exclusivity
|
||||||
if self.qdrant_url and self.qdrant_location:
|
if self.qdrant_url and self.qdrant_location:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -337,7 +357,6 @@ class Settings:
|
|||||||
Returns:
|
Returns:
|
||||||
Collection name string
|
Collection name string
|
||||||
"""
|
"""
|
||||||
import socket
|
|
||||||
|
|
||||||
# Use explicit override if user configured non-default value
|
# Use explicit override if user configured non-default value
|
||||||
if self.qdrant_collection != "nextcloud_content":
|
if self.qdrant_collection != "nextcloud_content":
|
||||||
@@ -356,6 +375,19 @@ class Settings:
|
|||||||
|
|
||||||
return f"{deployment_id}-{model_name}"
|
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:
|
def _get_semantic_search_enabled() -> bool:
|
||||||
"""Get semantic search enabled status, supporting both old and new variable names.
|
"""Get semantic search enabled status, supporting both old and new variable names.
|
||||||
@@ -491,6 +523,11 @@ def get_settings() -> Settings:
|
|||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl=(
|
||||||
|
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
),
|
||||||
|
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||||
# ADR-005: Token Audience Validation
|
# ADR-005: Token Audience Validation
|
||||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||||
@@ -556,3 +593,20 @@ def get_settings() -> Settings:
|
|||||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||||
== "true",
|
== "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 logging
|
||||||
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
@@ -240,8 +241,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode:
|
|||||||
Raises:
|
Raises:
|
||||||
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
||||||
"""
|
"""
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import logging
|
|||||||
from httpx import BasicAuth
|
from httpx import BasicAuth
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.context_helper import (
|
||||||
|
get_client_from_context,
|
||||||
|
get_session_client_from_context,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
@@ -80,11 +84,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
|
|
||||||
# OAuth mode (has 'nextcloud_host' attribute)
|
# OAuth mode (has 'nextcloud_host' attribute)
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
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:
|
if settings.enable_token_exchange:
|
||||||
# Mode 2: Exchange MCP token for Nextcloud token
|
# Mode 2: Exchange MCP token for Nextcloud token
|
||||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||||
@@ -131,7 +130,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
ValueError: If required session config fields are missing
|
ValueError: If required session config fields are missing
|
||||||
"""
|
"""
|
||||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
# 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()
|
session_config = get_smithery_session_config()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import tempfile
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
||||||
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
||||||
# causing it to return a string instead of a list[dict].
|
# causing it to return a string instead of a list[dict].
|
||||||
@@ -95,7 +97,6 @@ class PyMuPDFProcessor(DocumentProcessor):
|
|||||||
Raises:
|
Raises:
|
||||||
ProcessorError: If PDF processing fails
|
ProcessorError: If PDF processing fails
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if progress_callback:
|
if progress_callback:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import anyio
|
||||||
from fastembed import SparseTextEmbedding
|
from fastembed import SparseTextEmbedding
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -67,7 +68,6 @@ class BM25SparseEmbeddingProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Run CPU-bound BM25 encoding in thread pool
|
# Run CPU-bound BM25 encoding in thread pool
|
||||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||||
@@ -82,7 +82,6 @@ class BM25SparseEmbeddingProvider:
|
|||||||
Returns:
|
Returns:
|
||||||
List of dictionaries with 'indices' and 'values' for each text
|
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
|
# 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]
|
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 logging
|
||||||
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
|
import nextcloud_mcp_server.alembic as alembic_package
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -29,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Alembic Config object configured for the specified database
|
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)
|
# Use package location (works in both editable and installed modes)
|
||||||
if alembic_package.__file__ is None:
|
if alembic_package.__file__ is None:
|
||||||
raise RuntimeError("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:
|
Returns:
|
||||||
Current revision ID or None if not versioned
|
Current revision ID or None if not versioned
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
|
||||||
|
|
||||||
if database_path is None:
|
if database_path is None:
|
||||||
database_path = "/app/data/tokens.db"
|
database_path = "/app/data/tokens.db"
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
|||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
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):
|
class CalendarEvent(CalendarEventSummary):
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
|||||||
color: str = Field(description="The created label color")
|
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):
|
class LabelOperationResponse(StatusResponse):
|
||||||
"""Response model for label operations like update/delete."""
|
"""Response model for label operations like update/delete."""
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ and resource usage. Metrics are organized by category:
|
|||||||
- External Dependency Health Metrics
|
- External Dependency Health Metrics
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from prometheus_client import (
|
from prometheus_client import (
|
||||||
Counter,
|
Counter,
|
||||||
@@ -23,6 +25,8 @@ from prometheus_client import (
|
|||||||
start_http_server,
|
start_http_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -423,10 +427,6 @@ def instrument_tool(func):
|
|||||||
Returns:
|
Returns:
|
||||||
Wrapped function with metrics and tracing instrumentation
|
Wrapped function with metrics and tracing instrumentation
|
||||||
"""
|
"""
|
||||||
import functools
|
|
||||||
import time
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
"""Base interfaces and data structures for search algorithms."""
|
"""Base interfaces and data structures for search algorithms."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Protocol, runtime_checkable
|
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
|
@runtime_checkable
|
||||||
class NextcloudClientProtocol(Protocol):
|
class NextcloudClientProtocol(Protocol):
|
||||||
@@ -78,13 +85,6 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
|||||||
>>> if "note" in types:
|
>>> if "note" in types:
|
||||||
... # Search notes
|
... # 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__)
|
logger = logging.getLogger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ position markers for better visualization and understanding of search results.
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
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.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__)
|
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
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
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
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -162,11 +159,6 @@ async def _get_file_path_from_qdrant(
|
|||||||
File path string, or None if not found in Qdrant
|
File path string, or None if not found in Qdrant
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
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
|
Dictionary with board_id and stack_id, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -352,8 +339,6 @@ async def get_chunk_with_context(
|
|||||||
|
|
||||||
# Fetch adjacent chunks for context expansion
|
# Fetch adjacent chunks for context expansion
|
||||||
# Get chunk overlap from config to remove duplicate text
|
# Get chunk overlap from config to remove duplicate text
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
chunk_overlap = settings.document_chunk_overlap
|
chunk_overlap = settings.document_chunk_overlap
|
||||||
|
|
||||||
@@ -549,8 +534,6 @@ async def _fetch_document_text(
|
|||||||
# Extract text from PDF using PyMuPDF
|
# Extract text from PDF using PyMuPDF
|
||||||
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
||||||
# This ensures character offsets align between indexed chunks and retrieval
|
# This ensures character offsets align between indexed chunks and retrieval
|
||||||
import pymupdf
|
|
||||||
import pymupdf4llm
|
|
||||||
|
|
||||||
logger.debug(f"Extracting text from PDF: {file_path}")
|
logger.debug(f"Extracting text from PDF: {file_path}")
|
||||||
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
||||||
@@ -586,8 +569,6 @@ async def _fetch_document_text(
|
|||||||
return None
|
return None
|
||||||
elif doc_type == "news_item":
|
elif doc_type == "news_item":
|
||||||
# Fetch news item by ID
|
# 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))
|
item = await nc_client.news.get_item(int(doc_id))
|
||||||
# Reconstruct full content as indexed: title + source + URL + body
|
# Reconstruct full content as indexed: title + source + URL + body
|
||||||
# This ensures chunk offsets align with indexed content structure
|
# This ensures chunk offsets align with indexed content structure
|
||||||
|
|||||||
@@ -10,10 +10,16 @@ varies between indexing and rendering.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pymupdf4llm
|
import pymupdf4llm
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -77,8 +83,6 @@ class PDFHighlighter:
|
|||||||
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
||||||
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||||
"""
|
"""
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
page_boundaries = []
|
page_boundaries = []
|
||||||
text_parts = []
|
text_parts = []
|
||||||
@@ -110,7 +114,6 @@ class PDFHighlighter:
|
|||||||
full_text = "".join(text_parts)
|
full_text = "".join(text_parts)
|
||||||
|
|
||||||
# Clean up temp directory and extracted images
|
# Clean up temp directory and extracted images
|
||||||
import shutil
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(temp_dir)
|
shutil.rmtree(temp_dir)
|
||||||
@@ -590,8 +593,6 @@ class PDFHighlighter:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
||||||
"""
|
"""
|
||||||
import tempfile
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
temp_pdf_path = None
|
temp_pdf_path = None
|
||||||
try:
|
try:
|
||||||
@@ -684,8 +685,6 @@ class PDFHighlighter:
|
|||||||
# Clean up temp directory and PDF file
|
# Clean up temp directory and PDF file
|
||||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||||
try:
|
try:
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(temp_pdf_path.parent)
|
shutil.rmtree(temp_pdf_path.parent)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -722,11 +721,6 @@ class PDFHighlighter:
|
|||||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||||
Chunks that fail to highlight are omitted from the result.
|
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]] = {}
|
results: dict[int, tuple[bytes, int, int]] = {}
|
||||||
|
|
||||||
if not chunks:
|
if not chunks:
|
||||||
@@ -800,9 +794,6 @@ class PDFHighlighter:
|
|||||||
|
|
||||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||||
# This avoids expensive page.get_pixmap() calls per chunk
|
# 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)
|
# PIL color for bounding box (RGB tuple)
|
||||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||||
|
|||||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
|||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.calendar import (
|
from nextcloud_mcp_server.models.calendar import (
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarEventSummary,
|
||||||
ListCalendarsResponse,
|
ListCalendarsResponse,
|
||||||
|
ListEventsResponse,
|
||||||
ListTodosResponse,
|
ListTodosResponse,
|
||||||
Todo,
|
Todo,
|
||||||
|
UpcomingEventsResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
filters=filters if filters else None,
|
filters=filters if filters else None,
|
||||||
)
|
)
|
||||||
return events[:limit]
|
events = events[:limit]
|
||||||
else:
|
else:
|
||||||
# Search in specific calendar
|
# Search in specific calendar
|
||||||
events = await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
limit=limit,
|
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
|
# Apply filters if provided
|
||||||
if filters:
|
if filters:
|
||||||
events = client.calendar._apply_event_filters(events, 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(
|
@mcp.tool(
|
||||||
title="Get Calendar Event",
|
title="Get Calendar Event",
|
||||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
if calendar_name:
|
if calendar_name:
|
||||||
# Get events from specific calendar
|
# Get events from specific calendar
|
||||||
return await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar_name,
|
calendar_name=calendar_name,
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
# calendar_display_name not available without extra API call
|
||||||
|
for event in events:
|
||||||
|
event["calendar_name"] = calendar_name
|
||||||
else:
|
else:
|
||||||
# Get events from all calendars
|
# Get events from all calendars
|
||||||
all_calendars = await client.calendar.list_calendars()
|
all_calendars = await client.calendar.list_calendars()
|
||||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
for calendar in all_calendars:
|
for calendar in all_calendars:
|
||||||
try:
|
try:
|
||||||
events = await client.calendar.get_calendar_events(
|
cal_events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar["name"],
|
calendar_name=calendar["name"],
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
# Add calendar info to each event
|
for event in cal_events:
|
||||||
for event in events:
|
|
||||||
event["calendar_name"] = calendar["name"]
|
event["calendar_name"] = calendar["name"]
|
||||||
event["calendar_display_name"] = calendar["display_name"]
|
event["calendar_display_name"] = calendar["display_name"]
|
||||||
all_events.extend(events)
|
all_events.extend(cal_events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
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
|
# Sort by start time and limit
|
||||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
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(
|
@mcp.tool(
|
||||||
title="Find Availability",
|
title="Find Availability",
|
||||||
|
|||||||
@@ -1,15 +1,57 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.types import ToolAnnotations
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
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
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||||
|
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||||
|
|
||||||
|
Only maps fields the client's list_contacts() currently returns:
|
||||||
|
fullname, nickname, birthday, and email. Additional Contact model fields
|
||||||
|
(phones, addresses, organization, etc.) require expanding the client's
|
||||||
|
vCard parsing in ContactsClient.list_contacts().
|
||||||
|
"""
|
||||||
|
contact_info = raw.get("contact", {})
|
||||||
|
|
||||||
|
# Convert email field (str, list, or None) to list[ContactField]
|
||||||
|
raw_email = contact_info.get("email")
|
||||||
|
emails: list[ContactField] = []
|
||||||
|
if isinstance(raw_email, list):
|
||||||
|
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||||
|
elif isinstance(raw_email, str) and raw_email:
|
||||||
|
emails = [ContactField(type="email", value=raw_email)]
|
||||||
|
|
||||||
|
# Nickname goes into custom_fields (no dedicated model field)
|
||||||
|
custom_fields: dict[str, Any] = {}
|
||||||
|
nickname = contact_info.get("nickname")
|
||||||
|
if nickname:
|
||||||
|
custom_fields["nickname"] = nickname
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
uid=raw["vcard_id"],
|
||||||
|
fn=contact_info.get("fullname", ""),
|
||||||
|
etag=raw.get("getetag"),
|
||||||
|
birthday=contact_info.get("birthday"),
|
||||||
|
emails=emails,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -18,10 +60,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@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."""
|
"""List all addressbooks for the user."""
|
||||||
client = await get_client(ctx)
|
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(
|
@mcp.tool(
|
||||||
title="List Contacts",
|
title="List Contacts",
|
||||||
@@ -29,10 +84,16 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(
|
||||||
|
ctx: Context, *, addressbook: str
|
||||||
|
) -> ListContactsResponse:
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = await get_client(ctx)
|
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(
|
@mcp.tool(
|
||||||
title="Create Address Book",
|
title="Create Address Book",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
DeckLabel,
|
DeckLabel,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
LabelOperationResponse,
|
LabelOperationResponse,
|
||||||
|
ListBoardsResponse,
|
||||||
|
ListCardsResponse,
|
||||||
|
ListLabelsResponse,
|
||||||
|
ListStacksResponse,
|
||||||
StackOperationResponse,
|
StackOperationResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
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}")
|
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||||
async def deck_label_resource(board_id: int, label_id: int):
|
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")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Board",
|
title="Get Deck Board",
|
||||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@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"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Stack",
|
title="Get Deck Stack",
|
||||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> ListCardsResponse:
|
||||||
"""Get all cards in a Nextcloud Deck stack"""
|
"""Get all cards in a Nextcloud Deck stack"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
if stack.cards:
|
cards = stack.cards or []
|
||||||
return stack.cards
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
return []
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Card",
|
title="Get Deck Card",
|
||||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@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"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
labels = board.labels or []
|
||||||
|
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Label",
|
title="Get Deck Label",
|
||||||
@@ -637,7 +641,9 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Remove Label from Deck Card",
|
title="Remove Label from Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
@@ -692,7 +698,9 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Unassign User from Deck Card",
|
title="Unassign User from Deck Card",
|
||||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import jwt
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
@@ -19,9 +20,13 @@ from mcp.types import ToolAnnotations
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -53,8 +58,6 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
|||||||
# Try JWT decode first
|
# Try JWT decode first
|
||||||
if is_jwt:
|
if is_jwt:
|
||||||
try:
|
try:
|
||||||
import jwt
|
|
||||||
|
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
user_id = payload.get("sub", "unknown")
|
user_id = payload.get("sub", "unknown")
|
||||||
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
|
||||||
@@ -70,7 +73,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
|||||||
"OIDC_DISCOVERY_URI",
|
"OIDC_DISCOVERY_URI",
|
||||||
"http://localhost:8080/.well-known/openid-configuration",
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||||
discovery_response.raise_for_status()
|
discovery_response.raise_for_status()
|
||||||
discovery = discovery_response.json()
|
discovery = discovery_response.json()
|
||||||
@@ -157,11 +160,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
Returns:
|
Returns:
|
||||||
ProvisioningStatus with current provisioning state
|
ProvisioningStatus with current provisioning state
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Check for app password first (interim solution)
|
# Check for app password first (interim solution)
|
||||||
@@ -303,16 +301,15 @@ async def provision_nextcloud_access(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get configuration
|
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
||||||
enable_offline_access = (
|
# and ENABLE_OFFLINE_ACCESS environment variables)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
settings = get_settings()
|
||||||
)
|
if not settings.enable_offline_access:
|
||||||
if not enable_offline_access:
|
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=(
|
message=(
|
||||||
"Offline access is not enabled. "
|
"Offline access is not enabled. "
|
||||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
"Set ENABLE_BACKGROUND_OPERATIONS=true to use this feature."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -488,13 +485,12 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
|||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|
||||||
# Not logged in - generate OAuth URL for Flow 2
|
# Not logged in - generate OAuth URL for Flow 2
|
||||||
enable_offline_access = (
|
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
settings = get_settings()
|
||||||
)
|
if not settings.enable_offline_access:
|
||||||
if not enable_offline_access:
|
|
||||||
return (
|
return (
|
||||||
"Not logged in. Offline access is not enabled. "
|
"Not logged in. Offline access is not enabled. "
|
||||||
"Set ENABLE_OFFLINE_ACCESS=true to use this feature."
|
"Set ENABLE_BACKGROUND_OPERATIONS=true to use this feature."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get MCP server's OAuth client credentials
|
# Get MCP server's OAuth client credentials
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from httpx import RequestError
|
|||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
ClientCapabilities,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
ModelHint,
|
ModelHint,
|
||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
|
SamplingCapability,
|
||||||
SamplingMessage,
|
SamplingMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
ToolAnnotations,
|
ToolAnnotations,
|
||||||
)
|
)
|
||||||
|
from qdrant_client.models import Filter
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.semantic import (
|
from nextcloud_mcp_server.models.semantic import (
|
||||||
SamplingSearchResponse,
|
SamplingSearchResponse,
|
||||||
@@ -28,6 +32,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -82,8 +88,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
username = client.username
|
username = client.username
|
||||||
@@ -373,8 +377,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Check if client supports sampling
|
# 3. Check if client supports sampling
|
||||||
from mcp.types import ClientCapabilities, SamplingCapability
|
|
||||||
|
|
||||||
client_has_sampling = ctx.session.check_client_capability(
|
client_has_sampling = ctx.session.check_client_capability(
|
||||||
ClientCapabilities(sampling=SamplingCapability())
|
ClientCapabilities(sampling=SamplingCapability())
|
||||||
)
|
)
|
||||||
@@ -656,14 +658,10 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
This is useful for determining when vector indexing is complete
|
This is useful for determining when vector indexing is complete
|
||||||
after creating or updating content across all indexed apps.
|
after creating or updating content across all indexed apps.
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
|
|
||||||
# Check if vector sync is enabled
|
# Check if vector sync is enabled (supports both old and new env var names)
|
||||||
vector_sync_enabled = (
|
settings = get_settings()
|
||||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
if not settings.vector_sync_enabled:
|
||||||
)
|
|
||||||
|
|
||||||
if not vector_sync_enabled:
|
|
||||||
return VectorSyncStatusResponse(
|
return VectorSyncStatusResponse(
|
||||||
indexed_count=0,
|
indexed_count=0,
|
||||||
pending_count=0,
|
pending_count=0,
|
||||||
@@ -696,15 +694,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import Filter
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.placeholder import (
|
|
||||||
get_placeholder_filter,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection, excluding placeholders
|
# Count documents in collection, excluding placeholders
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
|
|||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_list_tables(ctx: Context):
|
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
tables_data = await client.tables.list_tables()
|
||||||
|
tables = [Table(**t) for t in tables_data]
|
||||||
|
return ListTablesResponse(tables=tables, total_count=len(tables))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Table Schema",
|
title="Get Table Schema",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
@@ -120,7 +121,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
# For binary files, return metadata and base64 encoded content
|
# For binary files, return metadata and base64 encoded content
|
||||||
import base64
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"path": path,
|
"path": path,
|
||||||
@@ -156,8 +156,6 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Handle base64 encoded content
|
# Handle base64 encoded content
|
||||||
if content_type and "base64" in content_type.lower():
|
if content_type and "base64" in content_type.lower():
|
||||||
import base64
|
|
||||||
|
|
||||||
content_bytes = base64.b64decode(content)
|
content_bytes = base64.b64decode(content)
|
||||||
content_type = content_type.replace(";base64", "")
|
content_type = content_type.replace(";base64", "")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def main():
|
|||||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||||
|
|
||||||
# Import app after setting environment variables
|
# Import app after setting environment variables
|
||||||
from nextcloud_mcp_server.app import get_app
|
from nextcloud_mcp_server.app import get_app # noqa: PLC0415
|
||||||
|
|
||||||
# Create the app with streamable-http transport (required for Smithery)
|
# Create the app with streamable-http transport (required for Smithery)
|
||||||
app = get_app(transport="streamable-http")
|
app = get_app(transport="streamable-http")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import anyio
|
||||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,7 +69,6 @@ class DocumentChunker:
|
|||||||
Returns:
|
Returns:
|
||||||
List of chunks with their character positions in the original content
|
List of chunks with their character positions in the original content
|
||||||
"""
|
"""
|
||||||
import anyio
|
|
||||||
|
|
||||||
# Handle empty content - return single empty chunk for backward compatibility
|
# Handle empty content - return single empty chunk for backward compatibility
|
||||||
if not content:
|
if not content:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""HTML to Markdown conversion utilities for vector sync."""
|
"""HTML to Markdown conversion utilities for vector sync."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from markdownify import markdownify as md
|
from markdownify import markdownify as md
|
||||||
|
|
||||||
@@ -43,7 +44,6 @@ def html_to_markdown(html_content: str | None) -> str:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
||||||
# Fallback: strip all HTML tags as a last resort
|
# Fallback: strip all HTML tags as a last resort
|
||||||
import re
|
|
||||||
|
|
||||||
text = re.sub(r"<[^>]+>", " ", html_content)
|
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||||
return " ".join(text.split()) # Normalize whitespace
|
return " ".join(text.split()) # Normalize whitespace
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
"""OAuth mode vector sync orchestration.
|
"""Multi-user vector sync orchestration.
|
||||||
|
|
||||||
Manages multi-user background vector sync when running in OAuth mode
|
Manages background vector sync for multi-user deployments:
|
||||||
with ENABLE_OFFLINE_ACCESS=true:
|
- User Manager: Monitors storage for user changes
|
||||||
- User Manager: Monitors RefreshTokenStorage for user changes
|
|
||||||
- Per-User Scanners: One scanner task per provisioned user
|
- Per-User Scanners: One scanner task per provisioned user
|
||||||
- Shared Processor Pool: Processes documents from all users
|
- Shared Processor Pool: Processes documents from all users
|
||||||
|
|
||||||
Supports dual credential types for background sync:
|
Authentication strategies are mutually exclusive by deployment mode:
|
||||||
- App passwords (interim solution, works today)
|
|
||||||
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
|
Multi-user BasicAuth mode (ENABLE_MULTI_USER_BASIC_AUTH=true):
|
||||||
|
- Uses app passwords stored locally in MCP server's database
|
||||||
|
- Users provision via Astrolabe personal settings, which sends to MCP API
|
||||||
|
- OAuth is NOT used
|
||||||
|
|
||||||
|
OAuth mode (with external IdP like Keycloak):
|
||||||
|
- Uses OAuth refresh tokens via TokenBrokerService
|
||||||
|
- Users provision via browser OAuth flow
|
||||||
|
- App passwords are NOT used
|
||||||
|
|
||||||
|
These are separate concerns - no fallback between them.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -22,15 +31,15 @@ from anyio.streams.memory import (
|
|||||||
MemoryObjectReceiveStream,
|
MemoryObjectReceiveStream,
|
||||||
MemoryObjectSendStream,
|
MemoryObjectSendStream,
|
||||||
)
|
)
|
||||||
from httpx import BasicAuth
|
from httpx import BasicAuth, HTTPStatusError
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.processor import process_document
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -59,16 +68,59 @@ class UserSyncState:
|
|||||||
started_at: float = field(default_factory=time.time)
|
started_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
async def get_user_client(
|
async def get_user_client_basic_auth(
|
||||||
|
user_id: str,
|
||||||
|
nextcloud_host: str,
|
||||||
|
storage: "RefreshTokenStorage | None" = None,
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Get an authenticated NextcloudClient using app password (BasicAuth mode).
|
||||||
|
|
||||||
|
For multi-user BasicAuth deployments where users provision app passwords
|
||||||
|
via Astrolabe personal settings. The app password is stored locally in the
|
||||||
|
MCP server's database after being provisioned through the management API.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
storage: Optional RefreshTokenStorage instance (created from env if not provided)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated NextcloudClient with BasicAuth
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotProvisionedError: If user has not provisioned an app password
|
||||||
|
"""
|
||||||
|
# Get or create storage instance
|
||||||
|
if storage is None:
|
||||||
|
storage = RefreshTokenStorage.from_env()
|
||||||
|
await storage.initialize()
|
||||||
|
|
||||||
|
# Retrieve app password from local storage
|
||||||
|
app_password = await storage.get_app_password(user_id)
|
||||||
|
|
||||||
|
if not app_password:
|
||||||
|
raise NotProvisionedError(
|
||||||
|
f"User {user_id} has not provisioned an app password. "
|
||||||
|
f"User must configure background sync in Astrolabe personal settings."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Using app password for background sync: {user_id}")
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
username=user_id,
|
||||||
|
auth=BasicAuth(user_id, app_password),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_client_oauth(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
) -> NextcloudClient:
|
) -> NextcloudClient:
|
||||||
"""Get an authenticated NextcloudClient for a user.
|
"""Get an authenticated NextcloudClient using OAuth refresh token.
|
||||||
|
|
||||||
Supports dual credential types with priority:
|
For OAuth deployments with external IdP where users provision via
|
||||||
1. App password from Astrolabe (works today with BasicAuth)
|
browser OAuth flow. App passwords are NOT used in this mode.
|
||||||
2. OAuth refresh token from storage (for future when OAuth fully supported)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User identifier
|
user_id: User identifier
|
||||||
@@ -76,45 +128,19 @@ async def get_user_client(
|
|||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Authenticated NextcloudClient
|
Authenticated NextcloudClient with Bearer token
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned offline access
|
NotProvisionedError: If user has not provisioned offline access
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
# Try app password first (interim solution, works today)
|
|
||||||
if settings.oidc_client_id and settings.oidc_client_secret:
|
|
||||||
try:
|
|
||||||
astrolabe = AstrolabeClient(
|
|
||||||
nextcloud_host=nextcloud_host,
|
|
||||||
client_id=settings.oidc_client_id,
|
|
||||||
client_secret=settings.oidc_client_secret,
|
|
||||||
)
|
|
||||||
app_password = await astrolabe.get_user_app_password(user_id)
|
|
||||||
|
|
||||||
if app_password:
|
|
||||||
logger.info(
|
|
||||||
f"Using app password for background sync: {user_id} "
|
|
||||||
f"(credential_type=app_password)"
|
|
||||||
)
|
|
||||||
return NextcloudClient(
|
|
||||||
base_url=nextcloud_host,
|
|
||||||
username=user_id,
|
|
||||||
auth=BasicAuth(user_id, app_password),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"App password not available for {user_id}: {e}")
|
|
||||||
|
|
||||||
# Fall back to OAuth refresh token
|
|
||||||
logger.info(
|
|
||||||
f"Using OAuth refresh token for background sync: {user_id} "
|
|
||||||
f"(credential_type=refresh_token)"
|
|
||||||
)
|
|
||||||
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
||||||
if not token:
|
if not token:
|
||||||
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
|
raise NotProvisionedError(
|
||||||
|
f"User {user_id} has not provisioned offline access. "
|
||||||
|
f"User must complete the OAuth provisioning flow."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Using OAuth refresh token for background sync: {user_id}")
|
||||||
return NextcloudClient.from_token(
|
return NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
token=token,
|
token=token,
|
||||||
@@ -122,39 +148,109 @@ async def get_user_client(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_client(
|
||||||
|
user_id: str,
|
||||||
|
token_broker: "TokenBrokerService | None",
|
||||||
|
nextcloud_host: str,
|
||||||
|
*,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Get an authenticated NextcloudClient for a user.
|
||||||
|
|
||||||
|
Dispatches to the appropriate authentication strategy based on mode.
|
||||||
|
These are mutually exclusive - no fallback between them.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
token_broker: Token broker for OAuth mode (can be None for BasicAuth mode)
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords via Astrolabe (BasicAuth mode).
|
||||||
|
If False, use OAuth refresh tokens (OAuth mode).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated NextcloudClient
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
NotProvisionedError: If user has not provisioned access for the mode
|
||||||
|
"""
|
||||||
|
if use_basic_auth:
|
||||||
|
return await get_user_client_basic_auth(user_id, nextcloud_host)
|
||||||
|
else:
|
||||||
|
if token_broker is None:
|
||||||
|
raise ValueError("token_broker required for OAuth mode")
|
||||||
|
return await get_user_client_oauth(user_id, token_broker, nextcloud_host)
|
||||||
|
|
||||||
|
|
||||||
async def user_scanner_task(
|
async def user_scanner_task(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
*,
|
*,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Scanner task for a single user in OAuth mode.
|
"""Scanner task for a single user.
|
||||||
|
|
||||||
Gets a fresh token at the start of each scan cycle.
|
Gets fresh credentials at the start of each scan cycle.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User to scan
|
user_id: User to scan
|
||||||
send_stream: Stream to send changed documents to processors
|
send_stream: Stream to send changed documents to processors
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
wake_event: Event to trigger immediate scan
|
wake_event: Event to trigger immediate scan
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
logger.info(f"[OAuth] Scanner started for user: {user_id}")
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
max_consecutive_errors = 5
|
||||||
|
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
|
# Pre-validate credentials before entering scan loop
|
||||||
|
try:
|
||||||
|
nc_client = await get_user_client(
|
||||||
|
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await nc_client.capabilities() # Lightweight OCS call to validate creds
|
||||||
|
logger.info(f"[{mode_label}] Credentials validated for {user_id}")
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Credential validation failed for {user_id} "
|
||||||
|
f"(HTTP {e.response.status_code}), not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await nc_client.close()
|
||||||
|
except NotProvisionedError:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] User {user_id} not provisioned, not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Pre-validation failed for {user_id}: {e}. "
|
||||||
|
f"Proceeding to scan loop (has its own error handling)."
|
||||||
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
nc_client = None
|
nc_client = None
|
||||||
try:
|
try:
|
||||||
# Get fresh token for this scan cycle
|
# Get fresh credentials for this scan cycle
|
||||||
nc_client = await get_user_client(user_id, token_broker, nextcloud_host)
|
nc_client = await get_user_client(
|
||||||
|
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||||
|
)
|
||||||
|
|
||||||
# Scan user's documents
|
# Scan user's documents
|
||||||
await scan_user_documents(
|
await scan_user_documents(
|
||||||
@@ -163,19 +259,64 @@ async def user_scanner_task(
|
|||||||
nc_client=nc_client,
|
nc_client=nc_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0 # Reset on success
|
||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth] User {user_id} no longer provisioned, stopping scanner"
|
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
status_code = e.response.status_code
|
||||||
|
if status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner auth failed for {user_id} "
|
||||||
|
f"(HTTP {status_code}), stopping scanner. "
|
||||||
|
f"User may need to re-provision credentials."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
elif status_code == 429:
|
||||||
|
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner rate-limited for {user_id}, "
|
||||||
|
f"backing off {retry_after}s"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(retry_after):
|
||||||
|
await shutdown_event.wait()
|
||||||
|
# anyio.get_cancelled_exc_class() catches task cancellation
|
||||||
|
# (e.g. from task group teardown) so we exit cleanly.
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
consecutive_errors += 1
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner HTTP error for {user_id}: {e} "
|
||||||
|
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[OAuth] Scanner error for {user_id}: {e}", exc_info=True)
|
consecutive_errors += 1
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner error for {user_id}: {e} "
|
||||||
|
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
await nc_client.close()
|
await nc_client.close()
|
||||||
|
|
||||||
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} "
|
||||||
|
f"consecutive errors, stopping scanner"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
# Sleep until next interval or wake event
|
# Sleep until next interval or wake event
|
||||||
try:
|
try:
|
||||||
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||||
@@ -183,33 +324,34 @@ async def user_scanner_task(
|
|||||||
except anyio.get_cancelled_exc_class():
|
except anyio.get_cancelled_exc_class():
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info(f"[OAuth] Scanner stopped for user: {user_id}")
|
logger.info(f"[{mode_label}] Scanner stopped for user: {user_id}")
|
||||||
|
|
||||||
|
|
||||||
async def oauth_processor_task(
|
async def multi_user_processor_task(
|
||||||
worker_id: int,
|
worker_id: int,
|
||||||
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
*,
|
*,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Processor task for OAuth mode.
|
"""Processor task for multi-user mode.
|
||||||
|
|
||||||
Handles documents from any user by fetching tokens on-demand.
|
Handles documents from any user by fetching credentials on-demand.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
worker_id: Worker identifier for logging
|
worker_id: Worker identifier for logging
|
||||||
receive_stream: Stream to receive documents from
|
receive_stream: Stream to receive documents from
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.vector.processor import process_document
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
||||||
logger.info(f"[OAuth] Processor {worker_id} started")
|
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
@@ -220,9 +362,12 @@ async def oauth_processor_task(
|
|||||||
with anyio.fail_after(1.0):
|
with anyio.fail_after(1.0):
|
||||||
doc_task = await receive_stream.receive()
|
doc_task = await receive_stream.receive()
|
||||||
|
|
||||||
# Get token for THIS document's user
|
# Get credentials for THIS document's user
|
||||||
nc_client = await get_user_client(
|
nc_client = await get_user_client(
|
||||||
doc_task.user_id, token_broker, nextcloud_host
|
doc_task.user_id,
|
||||||
|
token_broker,
|
||||||
|
nextcloud_host,
|
||||||
|
use_basic_auth=use_basic_auth,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process the document
|
# Process the document
|
||||||
@@ -232,13 +377,13 @@ async def oauth_processor_task(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
except anyio.EndOfStream:
|
except anyio.EndOfStream:
|
||||||
logger.info(f"[OAuth] Processor {worker_id}: Stream closed, exiting")
|
logger.info(f"[{mode_label}] Processor {worker_id}: Stream closed, exiting")
|
||||||
break
|
break
|
||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
if doc_task:
|
if doc_task:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[OAuth] User {doc_task.user_id} not provisioned, "
|
f"[{mode_label}] User {doc_task.user_id} not provisioned, "
|
||||||
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
|
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -246,18 +391,24 @@ async def oauth_processor_task(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
if doc_task:
|
if doc_task:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[OAuth] Processor {worker_id} error processing "
|
f"[{mode_label}] Processor {worker_id} error processing "
|
||||||
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"[OAuth] Processor {worker_id} error: {e}", exc_info=True)
|
logger.error(
|
||||||
|
f"[{mode_label}] Processor {worker_id} error: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
await nc_client.close()
|
await nc_client.close()
|
||||||
|
|
||||||
logger.info(f"[OAuth] Processor {worker_id} stopped")
|
logger.info(f"[{mode_label}] Processor {worker_id} stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# Backward compatibility alias
|
||||||
|
oauth_processor_task = multi_user_processor_task
|
||||||
|
|
||||||
|
|
||||||
async def _run_user_scanner_with_scope(
|
async def _run_user_scanner_with_scope(
|
||||||
@@ -266,9 +417,10 @@ async def _run_user_scanner_with_scope(
|
|||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
user_states: dict[str, UserSyncState],
|
user_states: dict[str, UserSyncState],
|
||||||
|
use_basic_auth: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Wrapper to run scanner with cancellation scope.
|
"""Wrapper to run scanner with cancellation scope.
|
||||||
|
|
||||||
@@ -284,6 +436,7 @@ async def _run_user_scanner_with_scope(
|
|||||||
wake_event=wake_event,
|
wake_event=wake_event,
|
||||||
token_broker=token_broker,
|
token_broker=token_broker,
|
||||||
nextcloud_host=nextcloud_host,
|
nextcloud_host=nextcloud_host,
|
||||||
|
use_basic_auth=use_basic_auth,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
# Clean up on exit
|
# Clean up on exit
|
||||||
@@ -296,48 +449,60 @@ async def user_manager_task(
|
|||||||
send_stream: MemoryObjectSendStream[DocumentTask],
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
shutdown_event: anyio.Event,
|
shutdown_event: anyio.Event,
|
||||||
wake_event: anyio.Event,
|
wake_event: anyio.Event,
|
||||||
token_broker: "TokenBrokerService",
|
token_broker: "TokenBrokerService | None",
|
||||||
refresh_token_storage: "RefreshTokenStorage",
|
refresh_token_storage: "RefreshTokenStorage",
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
user_states: dict[str, UserSyncState],
|
user_states: dict[str, UserSyncState],
|
||||||
tg: TaskGroup,
|
tg: TaskGroup,
|
||||||
|
use_basic_auth: bool = False,
|
||||||
*,
|
*,
|
||||||
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Supervisor task that manages per-user scanners.
|
"""Supervisor task that manages per-user scanners.
|
||||||
|
|
||||||
Periodically polls RefreshTokenStorage to detect:
|
Periodically polls storage to detect:
|
||||||
- New users who have provisioned offline access -> start scanner
|
- New users who have provisioned access -> start scanner
|
||||||
- Users who have revoked access -> cancel their scanner
|
- Users who have revoked access -> cancel their scanner
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
send_stream: Stream to send documents to processors
|
send_stream: Stream to send documents to processors
|
||||||
shutdown_event: Event signaling shutdown
|
shutdown_event: Event signaling shutdown
|
||||||
wake_event: Event to wake scanners for immediate scan
|
wake_event: Event to wake scanners for immediate scan
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for OAuth mode (None for BasicAuth mode)
|
||||||
refresh_token_storage: Storage for refresh tokens
|
refresh_token_storage: Storage for tracking provisioned users
|
||||||
nextcloud_host: Nextcloud base URL
|
nextcloud_host: Nextcloud base URL
|
||||||
user_states: Shared dict tracking active user scanners
|
user_states: Shared dict tracking active user scanners
|
||||||
tg: Task group for spawning scanner tasks
|
tg: Task group for spawning scanner tasks
|
||||||
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
poll_interval = settings.vector_sync_user_poll_interval
|
poll_interval = settings.vector_sync_user_poll_interval
|
||||||
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
|
|
||||||
logger.info(f"[OAuth] User manager started (poll interval: {poll_interval}s)")
|
logger.info(
|
||||||
|
f"[{mode_label}] User manager started (poll interval: {poll_interval}s)"
|
||||||
|
)
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
try:
|
try:
|
||||||
# Get current provisioned users
|
# Get current provisioned users based on mode
|
||||||
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
if use_basic_auth:
|
||||||
|
# BasicAuth mode: query app_passwords table
|
||||||
|
provisioned_users = set(
|
||||||
|
await refresh_token_storage.get_all_app_password_user_ids()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# OAuth mode: query refresh_tokens table
|
||||||
|
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||||
active_users = set(user_states.keys())
|
active_users = set(user_states.keys())
|
||||||
|
|
||||||
# Start scanners for new users
|
# Start scanners for new users
|
||||||
new_users = provisioned_users - active_users
|
new_users = provisioned_users - active_users
|
||||||
for user_id in new_users:
|
for user_id in new_users:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[OAuth] Starting scanner for newly provisioned user: {user_id}"
|
f"[{mode_label}] Starting scanner for newly provisioned user: {user_id}"
|
||||||
)
|
)
|
||||||
cancel_scope = anyio.CancelScope()
|
cancel_scope = anyio.CancelScope()
|
||||||
user_states[user_id] = UserSyncState(
|
user_states[user_id] = UserSyncState(
|
||||||
@@ -356,24 +521,27 @@ async def user_manager_task(
|
|||||||
token_broker,
|
token_broker,
|
||||||
nextcloud_host,
|
nextcloud_host,
|
||||||
user_states,
|
user_states,
|
||||||
|
use_basic_auth, # Positional after user_states
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cancel scanners for revoked users
|
# Cancel scanners for revoked users
|
||||||
revoked_users = active_users - provisioned_users
|
revoked_users = active_users - provisioned_users
|
||||||
for user_id in revoked_users:
|
for user_id in revoked_users:
|
||||||
logger.info(f"[OAuth] Stopping scanner for revoked user: {user_id}")
|
logger.info(
|
||||||
|
f"[{mode_label}] Stopping scanner for revoked user: {user_id}"
|
||||||
|
)
|
||||||
state = user_states.get(user_id)
|
state = user_states.get(user_id)
|
||||||
if state:
|
if state:
|
||||||
state.cancel_scope.cancel()
|
state.cancel_scope.cancel()
|
||||||
# Note: state will be removed by _run_user_scanner_with_scope on exit
|
# Note: state will be removed by _run_user_scanner_with_scope on exit
|
||||||
|
|
||||||
if new_users:
|
if new_users:
|
||||||
logger.info(f"[OAuth] Started {len(new_users)} new scanner(s)")
|
logger.info(f"[{mode_label}] Started {len(new_users)} new scanner(s)")
|
||||||
if revoked_users:
|
if revoked_users:
|
||||||
logger.info(f"[OAuth] Stopped {len(revoked_users)} scanner(s)")
|
logger.info(f"[{mode_label}] Stopped {len(revoked_users)} scanner(s)")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[OAuth] User manager error: {e}", exc_info=True)
|
logger.error(f"[{mode_label}] User manager error: {e}", exc_info=True)
|
||||||
|
|
||||||
# Sleep until next poll
|
# Sleep until next poll
|
||||||
try:
|
try:
|
||||||
@@ -384,9 +552,9 @@ async def user_manager_task(
|
|||||||
|
|
||||||
# Cancel all remaining scanners on shutdown
|
# Cancel all remaining scanners on shutdown
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[OAuth] User manager shutting down, cancelling {len(user_states)} scanner(s)"
|
f"[{mode_label}] User manager shutting down, cancelling {len(user_states)} scanner(s)"
|
||||||
)
|
)
|
||||||
for state in list(user_states.values()):
|
for state in list(user_states.values()):
|
||||||
state.cancel_scope.cancel()
|
state.cancel_scope.cancel()
|
||||||
|
|
||||||
logger.info("[OAuth] User manager stopped")
|
logger.info(f"[{mode_label}] User manager stopped")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from qdrant_client import models
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
@@ -82,8 +83,6 @@ async def write_placeholder_point(
|
|||||||
|
|
||||||
# Create empty sparse vector for placeholders
|
# Create empty sparse vector for placeholders
|
||||||
# Use models.SparseVector with empty indices/values
|
# Use models.SparseVector with empty indices/values
|
||||||
from qdrant_client import models
|
|
||||||
|
|
||||||
empty_sparse = models.SparseVector(indices=[], values=[])
|
empty_sparse = models.SparseVector(indices=[], values=[])
|
||||||
|
|
||||||
# Generate deterministic point ID
|
# Generate deterministic point ID
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
Processes documents from stream: fetches content, generates embeddings, stores in Qdrant.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -16,6 +17,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
|||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||||
from nextcloud_mcp_server.observability.metrics import (
|
from nextcloud_mcp_server.observability.metrics import (
|
||||||
record_qdrant_operation,
|
record_qdrant_operation,
|
||||||
@@ -23,7 +25,9 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
update_vector_sync_queue_size,
|
update_vector_sync_queue_size,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||||
@@ -274,8 +278,6 @@ async def _index_document(
|
|||||||
content_bytes = None # Notes don't have binary content
|
content_bytes = None # Notes don't have binary content
|
||||||
content_type = None
|
content_type = None
|
||||||
elif doc_task.doc_type == "news_item":
|
elif doc_task.doc_type == "news_item":
|
||||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
|
||||||
|
|
||||||
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||||
# Convert HTML body to Markdown for better embedding
|
# Convert HTML body to Markdown for better embedding
|
||||||
body_markdown = html_to_markdown(item.get("body", ""))
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
@@ -436,8 +438,6 @@ async def _index_document(
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
# Use document processor registry to extract text
|
# Use document processor registry to extract text
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -585,10 +585,6 @@ async def _index_document(
|
|||||||
"vector_sync.pdf_size": len(content_bytes),
|
"vector_sync.pdf_size": len(content_bytes),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
import base64
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
|
||||||
|
|
||||||
# Build chunk data for batch processing
|
# Build chunk data for batch processing
|
||||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from qdrant_client import AsyncQdrantClient, models
|
|||||||
from qdrant_client.models import Distance, VectorParams
|
from qdrant_client.models import Distance, VectorParams
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,9 +63,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
|||||||
# Get collection name (auto-generated from deployment ID + model)
|
# Get collection name (auto-generated from deployment ID + model)
|
||||||
collection_name = settings.get_collection_name()
|
collection_name = settings.get_collection_name()
|
||||||
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
|
|
||||||
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ Periodically scans enabled users' content and queues changed documents for proce
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
@@ -14,6 +16,7 @@ from anyio.streams.memory import MemoryObjectSendStream
|
|||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
@@ -167,7 +170,6 @@ async def scan_user_documents(
|
|||||||
nc_client: Authenticated Nextcloud client
|
nc_client: Authenticated Nextcloud client
|
||||||
initial_sync: If True, send all documents (first-time sync)
|
initial_sync: If True, send all documents (first-time sync)
|
||||||
"""
|
"""
|
||||||
import random
|
|
||||||
|
|
||||||
scan_id = random.randint(1000, 9999)
|
scan_id = random.randint(1000, 9999)
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -418,8 +420,6 @@ async def scan_user_documents(
|
|||||||
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
||||||
if isinstance(file_info.get("last_modified"), str):
|
if isinstance(file_info.get("last_modified"), str):
|
||||||
# Parse RFC 2822 date format if needed
|
# Parse RFC 2822 date format if needed
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(file_info["last_modified"])
|
dt = parsedate_to_datetime(file_info["last_modified"])
|
||||||
modified_at = int(dt.timestamp())
|
modified_at = int(dt.timestamp())
|
||||||
@@ -615,8 +615,6 @@ async def scan_news_items(
|
|||||||
Returns:
|
Returns:
|
||||||
Number of items queued for processing
|
Number of items queued for processing
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.client.news import NewsItemType
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
|
|||||||
+12
-10
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.57.0"
|
version = "0.64.4"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -10,10 +10,10 @@ license = {text = "AGPL-3.0-only"}
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.23,<1.24)",
|
"mcp[cli] (>=1.26,<1.27)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=7.0.2,<7.1.0)",
|
||||||
"pythonvcard4>=0.2.0",
|
"pythonvcard4>=0.2.0",
|
||||||
"pydantic>=2.11.4",
|
"pydantic>=2.11.4",
|
||||||
"click>=8.1.8",
|
"click>=8.1.8",
|
||||||
@@ -64,7 +64,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
anyio_mode = "auto"
|
anyio_mode = "auto"
|
||||||
addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio
|
addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio
|
||||||
log_cli = 1
|
log_cli = 1
|
||||||
log_cli_level = "ERROR"
|
log_cli_level = "ERROR"
|
||||||
log_level = "ERROR"
|
log_level = "ERROR"
|
||||||
@@ -98,23 +98,25 @@ version_files = [
|
|||||||
# Ignore tags from other components
|
# Ignore tags from other components
|
||||||
ignored_tag_formats = [
|
ignored_tag_formats = [
|
||||||
"nextcloud-mcp-server-*", # Helm chart tags
|
"nextcloud-mcp-server-*", # Helm chart tags
|
||||||
"astrolabe-v*", # Astrolabe tags
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filter commits by scope (all scopes except helm and astrolabe)
|
# Filter commits by scope (all scopes except helm)
|
||||||
[tool.commitizen.customize]
|
[tool.commitizen.customize]
|
||||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
|
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:"
|
||||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
extend-select = ["I"]
|
extend-select = ["I", "PLC0415"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["PLC0415"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Bump Astrolabe app version
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Parse optional --increment flag
|
|
||||||
INCREMENT=""
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--increment)
|
|
||||||
INCREMENT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Error: Unknown option: $1" >&2
|
|
||||||
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Validate dependencies
|
|
||||||
command -v uv >/dev/null 2>&1 || {
|
|
||||||
echo "❌ Error: uv not found" >&2
|
|
||||||
echo " Install from https://docs.astral.sh/uv/" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate Astrolabe directory exists
|
|
||||||
if [ ! -d "third_party/astrolabe" ]; then
|
|
||||||
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd third_party/astrolabe
|
|
||||||
|
|
||||||
# Validate required files exist
|
|
||||||
if [ ! -f "appinfo/info.xml" ]; then
|
|
||||||
echo "❌ Error: appinfo/info.xml not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "package.json" ]; then
|
|
||||||
echo "❌ Error: package.json not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Bumping Astrolabe version..."
|
|
||||||
if [ -n "$INCREMENT" ]; then
|
|
||||||
echo " Forcing $INCREMENT bump"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build commitizen command
|
|
||||||
CZ_CMD="uv run cz --config .cz.toml bump --yes"
|
|
||||||
if [ -n "$INCREMENT" ]; then
|
|
||||||
CZ_CMD="$CZ_CMD --increment $INCREMENT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run commitizen bump and capture output
|
|
||||||
if ! output=$($CZ_CMD 2>&1); then
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
# Check if this is the expected "no commits to bump" case
|
|
||||||
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
|
||||||
echo "ℹ️ No commits eligible for version bump" >&2
|
|
||||||
echo "$output" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Otherwise, this is an actual error
|
|
||||||
echo "❌ Error: Version bump failed" >&2
|
|
||||||
echo "$output" >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo "Common causes:" >&2
|
|
||||||
echo " - No commits with scope 'astrolabe' since last version" >&2
|
|
||||||
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
|
|
||||||
echo " - Git working directory not clean" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$output"
|
|
||||||
echo ""
|
|
||||||
echo "✓ Astrolabe version bumped successfully"
|
|
||||||
echo " Updated: appinfo/info.xml, package.json"
|
|
||||||
echo " Tag format: astrolabe-v\${version}"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " cd ../.."
|
|
||||||
echo " git push --follow-tags"
|
|
||||||
|
|
||||||
cd ../..
|
|
||||||
Executable
+145
@@ -0,0 +1,145 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Database query helper for development.
|
||||||
|
|
||||||
|
Wraps `docker compose exec db mariadb` to execute SQL statements against
|
||||||
|
the Nextcloud MariaDB database.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/dbquery.py "SELECT * FROM oc_notes LIMIT 5"
|
||||||
|
./scripts/dbquery.py -u root -p password "SHOW TABLES"
|
||||||
|
./scripts/dbquery.py --json "SELECT * FROM oc_oidc_clients"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def find_compose_dir() -> Path:
|
||||||
|
"""Find the directory containing docker-compose.yml."""
|
||||||
|
current = Path(__file__).resolve().parent
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / "docker-compose.yml").exists():
|
||||||
|
return current
|
||||||
|
if (current / "compose.yml").exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
# Default to script's parent directory
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def run_query(
|
||||||
|
sql: str,
|
||||||
|
user: str = "root",
|
||||||
|
password: str = "password",
|
||||||
|
database: str = "nextcloud",
|
||||||
|
vertical: bool = False,
|
||||||
|
json_output: bool = False,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""
|
||||||
|
Execute SQL via docker compose exec.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (return_code, stdout, stderr)
|
||||||
|
"""
|
||||||
|
compose_dir = find_compose_dir()
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T", # Disable pseudo-TTY allocation
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
f"-u{user}",
|
||||||
|
f"-p{password}",
|
||||||
|
database,
|
||||||
|
"-e",
|
||||||
|
sql,
|
||||||
|
]
|
||||||
|
|
||||||
|
if vertical:
|
||||||
|
cmd.insert(-2, "-E") # Vertical output format
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=compose_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Execute SQL queries against the Nextcloud MariaDB database",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s "SELECT COUNT(*) FROM oc_notes"
|
||||||
|
%(prog)s "SELECT id, name FROM oc_oidc_clients"
|
||||||
|
%(prog)s -E "SELECT * FROM oc_users LIMIT 1"
|
||||||
|
%(prog)s --user nextcloud --password nextcloud "SHOW TABLES"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("sql", help="SQL statement to execute")
|
||||||
|
parser.add_argument(
|
||||||
|
"-u", "--user", default="root", help="Database user (default: root)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-p",
|
||||||
|
"--password",
|
||||||
|
default="password",
|
||||||
|
help="Database password (default: password)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--database",
|
||||||
|
default="nextcloud",
|
||||||
|
help="Database name (default: nextcloud)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-E",
|
||||||
|
"--vertical",
|
||||||
|
action="store_true",
|
||||||
|
help="Print output vertically (one column per line)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
dest="json_output",
|
||||||
|
help="Request JSON output (if supported)",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
returncode, stdout, stderr = run_query(
|
||||||
|
sql=args.sql,
|
||||||
|
user=args.user,
|
||||||
|
password=args.password,
|
||||||
|
database=args.database,
|
||||||
|
vertical=args.vertical,
|
||||||
|
json_output=args.json_output,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
print(stdout, end="")
|
||||||
|
if stderr:
|
||||||
|
# Filter out the password warning
|
||||||
|
filtered_stderr = "\n".join(
|
||||||
|
line
|
||||||
|
for line in stderr.splitlines()
|
||||||
|
if "Using a password on the command line interface can be insecure"
|
||||||
|
not in line
|
||||||
|
)
|
||||||
|
if filtered_stderr:
|
||||||
|
print(filtered_stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Executable
+177
@@ -0,0 +1,177 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
SQLite database query helper for MCP service development.
|
||||||
|
|
||||||
|
Wraps `docker compose exec <service> sqlite3` to execute SQL statements
|
||||||
|
against the token storage database in any MCP service container.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
./scripts/sqlitequery.py ".tables"
|
||||||
|
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
|
||||||
|
./scripts/sqlitequery.py -s keycloak --headers "SELECT * FROM oauth_clients"
|
||||||
|
./scripts/sqlitequery.py --json "SELECT * FROM audit_logs LIMIT 5"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Service name aliases for convenience
|
||||||
|
SERVICE_ALIASES = {
|
||||||
|
"mcp": "mcp",
|
||||||
|
"oauth": "mcp-oauth",
|
||||||
|
"mcp-oauth": "mcp-oauth",
|
||||||
|
"keycloak": "mcp-keycloak",
|
||||||
|
"mcp-keycloak": "mcp-keycloak",
|
||||||
|
"basic": "mcp-multi-user-basic",
|
||||||
|
"multi-user-basic": "mcp-multi-user-basic",
|
||||||
|
"mcp-multi-user-basic": "mcp-multi-user-basic",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def find_compose_dir() -> Path:
|
||||||
|
"""Find the directory containing docker-compose.yml."""
|
||||||
|
current = Path(__file__).resolve().parent
|
||||||
|
while current != current.parent:
|
||||||
|
if (current / "docker-compose.yml").exists():
|
||||||
|
return current
|
||||||
|
if (current / "compose.yml").exists():
|
||||||
|
return current
|
||||||
|
current = current.parent
|
||||||
|
# Default to script's parent directory
|
||||||
|
return Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_service(service: str) -> str:
|
||||||
|
"""Resolve service alias to container name."""
|
||||||
|
resolved = SERVICE_ALIASES.get(service.lower())
|
||||||
|
if resolved is None:
|
||||||
|
# Not a known alias, use as-is (might be a custom service)
|
||||||
|
return service
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def run_query(
|
||||||
|
sql: str,
|
||||||
|
service: str = "mcp",
|
||||||
|
database: str = "/app/data/tokens.db",
|
||||||
|
headers: bool = False,
|
||||||
|
json_output: bool = False,
|
||||||
|
column_mode: bool = False,
|
||||||
|
) -> tuple[int, str, str]:
|
||||||
|
"""
|
||||||
|
Execute SQL via docker compose exec.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (return_code, stdout, stderr)
|
||||||
|
"""
|
||||||
|
compose_dir = find_compose_dir()
|
||||||
|
container = resolve_service(service)
|
||||||
|
|
||||||
|
# Build sqlite3 command with options
|
||||||
|
sqlite_args = []
|
||||||
|
|
||||||
|
# Set output mode
|
||||||
|
if json_output:
|
||||||
|
sqlite_args.extend(["-json"])
|
||||||
|
elif column_mode:
|
||||||
|
sqlite_args.extend(["-column"])
|
||||||
|
|
||||||
|
# Enable headers
|
||||||
|
if headers or column_mode:
|
||||||
|
sqlite_args.extend(["-header"])
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T", # Disable pseudo-TTY allocation
|
||||||
|
container,
|
||||||
|
"sqlite3",
|
||||||
|
*sqlite_args,
|
||||||
|
database,
|
||||||
|
sql,
|
||||||
|
]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=compose_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result.returncode, result.stdout, result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Execute SQL queries against SQLite databases in MCP service containers",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Services:
|
||||||
|
mcp Single-user BasicAuth mode (default)
|
||||||
|
oauth Nextcloud OAuth mode (mcp-oauth)
|
||||||
|
keycloak Keycloak OAuth mode (mcp-keycloak)
|
||||||
|
basic Multi-user BasicAuth mode (mcp-multi-user-basic)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
%(prog)s ".tables"
|
||||||
|
%(prog)s -s oauth "SELECT user_id FROM refresh_tokens"
|
||||||
|
%(prog)s -s keycloak ".schema oauth_clients"
|
||||||
|
%(prog)s --headers "SELECT * FROM audit_logs LIMIT 5"
|
||||||
|
%(prog)s --json "SELECT * FROM oauth_sessions"
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("sql", help="SQL statement or SQLite command to execute")
|
||||||
|
parser.add_argument(
|
||||||
|
"-s",
|
||||||
|
"--service",
|
||||||
|
default="mcp",
|
||||||
|
help="Target service (mcp, oauth, keycloak, basic) (default: mcp)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--database",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
help="Database path inside container (default: /app/data/tokens.db)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--headers",
|
||||||
|
action="store_true",
|
||||||
|
help="Show column headers",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
dest="json_output",
|
||||||
|
help="Output in JSON format",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--column",
|
||||||
|
action="store_true",
|
||||||
|
dest="column_mode",
|
||||||
|
help="Output in column format with headers",
|
||||||
|
)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
returncode, stdout, stderr = run_query(
|
||||||
|
sql=args.sql,
|
||||||
|
service=args.service,
|
||||||
|
database=args.database,
|
||||||
|
headers=args.headers,
|
||||||
|
json_output=args.json_output,
|
||||||
|
column_mode=args.column_mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stdout:
|
||||||
|
print(stdout, end="")
|
||||||
|
if stderr:
|
||||||
|
print(stderr, file=sys.stderr)
|
||||||
|
|
||||||
|
return returncode
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_extended_fields(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
|
||||||
|
tomorrow = datetime.now() + timedelta(days=1)
|
||||||
|
event_data = {
|
||||||
|
"title": "Extended Fields Update Test",
|
||||||
|
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Base event for extended-field update test",
|
||||||
|
}
|
||||||
|
|
||||||
|
event_uid = None
|
||||||
|
try:
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||||
|
|
||||||
|
# --- Phase 1: Set all four extended fields ---
|
||||||
|
updated_data = {
|
||||||
|
"categories": "work,meeting",
|
||||||
|
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||||
|
"attendees": "alice@example.com,bob@example.com",
|
||||||
|
"reminder_minutes": 15,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||||
|
|
||||||
|
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories
|
||||||
|
assert "work" in retrieved.get("categories", "")
|
||||||
|
assert "meeting" in retrieved.get("categories", "")
|
||||||
|
|
||||||
|
# Verify recurrence rule
|
||||||
|
assert retrieved.get("recurring") is True
|
||||||
|
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||||
|
|
||||||
|
# Verify attendees
|
||||||
|
attendees = retrieved.get("attendees", "")
|
||||||
|
assert "alice@example.com" in attendees
|
||||||
|
assert "bob@example.com" in attendees
|
||||||
|
|
||||||
|
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||||
|
|
||||||
|
# --- Phase 2: Clear all four extended fields ---
|
||||||
|
cleared_data = {
|
||||||
|
"categories": "",
|
||||||
|
"recurrence_rule": "",
|
||||||
|
"attendees": "",
|
||||||
|
"reminder_minutes": 0,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||||
|
|
||||||
|
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories cleared
|
||||||
|
assert not cleared.get("categories")
|
||||||
|
|
||||||
|
# Verify recurrence cleared
|
||||||
|
assert cleared.get("recurring") is not True
|
||||||
|
assert not cleared.get("recurrence_rule")
|
||||||
|
|
||||||
|
# Verify attendees cleared
|
||||||
|
assert not cleared.get("attendees")
|
||||||
|
|
||||||
|
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Extended fields update test failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def test_create_event_with_attendees(
|
async def test_create_event_with_attendees(
|
||||||
nc_client: NextcloudClient, temporary_calendar: str
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
):
|
):
|
||||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_date_range_filtering(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that date range filtering actually excludes events outside the range.
|
||||||
|
|
||||||
|
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||||
|
but returned events from the entire calendar history, ignoring date filters.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
past_uid = None
|
||||||
|
future_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Event A: 30 days in the past
|
||||||
|
past_date = datetime.now() - timedelta(days=30)
|
||||||
|
past_event_data = {
|
||||||
|
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Event in the past for date range test",
|
||||||
|
}
|
||||||
|
result_past = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, past_event_data
|
||||||
|
)
|
||||||
|
past_uid = result_past["uid"]
|
||||||
|
logger.info(f"Created past event: {past_uid}")
|
||||||
|
|
||||||
|
# Create Event B: 1 day in the future
|
||||||
|
future_date = datetime.now() + timedelta(days=1)
|
||||||
|
future_event_data = {
|
||||||
|
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||||
|
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||||
|
"description": "Event in the future for date range test",
|
||||||
|
}
|
||||||
|
result_future = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, future_event_data
|
||||||
|
)
|
||||||
|
future_uid = result_future["uid"]
|
||||||
|
logger.info(f"Created future event: {future_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 7 days ahead
|
||||||
|
now = datetime.now()
|
||||||
|
week_ahead = now + timedelta(days=7)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=now,
|
||||||
|
end_datetime=week_ahead,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
event_uids = [e["uid"] for e in events]
|
||||||
|
|
||||||
|
# Future event (tomorrow) SHOULD be in results
|
||||||
|
assert future_uid in event_uids, (
|
||||||
|
f"Future event {future_uid} should be in date-filtered results"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Past event (30 days ago) should NOT be in results
|
||||||
|
assert past_uid not in event_uids, (
|
||||||
|
f"Past event {past_uid} should be excluded by date range filter "
|
||||||
|
f"(GH-538: date range was being ignored)"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Date range filtering works: {len(events)} events returned, "
|
||||||
|
f"past event correctly excluded"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup both events
|
||||||
|
for uid in [past_uid, future_uid]:
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recurring_event_date_range_expansion(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that recurring events are expanded into individual occurrences.
|
||||||
|
|
||||||
|
When querying with a date range, a recurring event should return one
|
||||||
|
event dict per occurrence within the range, each with the correct
|
||||||
|
start_datetime for that occurrence (not the original master event date).
|
||||||
|
|
||||||
|
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||||
|
recurring events, but returned the master event with its original DTSTART
|
||||||
|
instead of expanding occurrences.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
event_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a daily recurring event starting 7 days ago
|
||||||
|
start = datetime.now() - timedelta(days=7)
|
||||||
|
event_data = {
|
||||||
|
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||||
|
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"description": "Daily recurring event for expansion test",
|
||||||
|
"recurring": True,
|
||||||
|
"recurrence_rule": "FREQ=DAILY",
|
||||||
|
}
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created daily recurring event: {event_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 3 days ahead
|
||||||
|
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
query_end = query_start + timedelta(days=3)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=query_start,
|
||||||
|
end_datetime=query_end,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter to only our recurring event (calendar may have others)
|
||||||
|
our_events = [e for e in events if e["uid"] == event_uid]
|
||||||
|
|
||||||
|
# Should have multiple occurrences (one per day in the range)
|
||||||
|
assert len(our_events) >= 2, (
|
||||||
|
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||||
|
f"Expansion may not be working."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each occurrence should have a different start_datetime
|
||||||
|
start_dates = [e["start_datetime"] for e in our_events]
|
||||||
|
assert len(set(start_dates)) == len(our_events), (
|
||||||
|
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No start_datetime should fall outside the queried range
|
||||||
|
for e in our_events:
|
||||||
|
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||||
|
# Remove timezone info for comparison if present
|
||||||
|
if event_start.tzinfo is not None:
|
||||||
|
event_start = event_start.replace(tzinfo=None)
|
||||||
|
assert event_start >= query_start - timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||||
|
)
|
||||||
|
assert event_start < query_end + timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expanded occurrences should NOT have recurrence rules
|
||||||
|
# (server strips RRULE when expanding)
|
||||||
|
for e in our_events:
|
||||||
|
assert not e.get("recurring"), (
|
||||||
|
"Expanded occurrence should not have recurring=True, "
|
||||||
|
"RRULE should be stripped by server-side expansion"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||||
|
f"returned with unique start dates"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def test_calendar_operations_error_handling(
|
async def test_calendar_operations_error_handling(
|
||||||
nc_client: NextcloudClient,
|
nc_client: NextcloudClient,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -22,14 +24,13 @@ def create_mock_response(
|
|||||||
Returns:
|
Returns:
|
||||||
Mock httpx.Response object
|
Mock httpx.Response object
|
||||||
"""
|
"""
|
||||||
import json as json_module
|
|
||||||
|
|
||||||
if headers is None:
|
if headers is None:
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
# If json_data is provided, serialize it to content
|
# If json_data is provided, serialize it to content
|
||||||
if json_data is not None:
|
if json_data is not None:
|
||||||
content = json_module.dumps(json_data).encode("utf-8")
|
content = json.dumps(json_data).encode("utf-8")
|
||||||
headers.setdefault("content-type", "application/json")
|
headers.setdefault("content-type", "application/json")
|
||||||
|
|
||||||
if content is None:
|
if content is None:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user