Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea96a58678 | |||
| 9b5c6779e9 | |||
| 04140d671e | |||
| e49dc2bfc4 | |||
| 4a5766b84e | |||
| 65c3f099fa | |||
| b293258210 | |||
| 8f83034c79 | |||
| d195fc43d2 | |||
| 1a5bb10cd0 | |||
| 34273ec01e | |||
| fd7f33943d | |||
| ecaa1f8f01 | |||
| 981f102b27 | |||
| 94febf1602 | |||
| 286a3eb20f | |||
| 19b209f412 | |||
| cd7ba5685a | |||
| 4507359760 | |||
| 8682fa4f88 | |||
| 53b84200d4 | |||
| f5e5965864 | |||
| 989c3d7541 | |||
| 4bda647271 | |||
| 32f3380205 | |||
| 0d6b8a935d | |||
| eece9ebadc | |||
| c390378278 | |||
| bd424a1ab7 | |||
| f8734b3edd | |||
| 0ea7145df1 | |||
| f7a3d2d8f5 | |||
| 18298177f7 | |||
| d9fa81082a | |||
| 651b73545d | |||
| 46505210cd | |||
| abf051afdb | |||
| d4d1a332fb | |||
| a3ed321e14 | |||
| 2bb738ed3f | |||
| 10c8b62818 | |||
| 87abadbbfc | |||
| defc55a5dc | |||
| 6a68e45e7c | |||
| a2fa4b2832 | |||
| 9cfadbfc04 | |||
| 6fed78196e | |||
| db430dd2c9 | |||
| 3618aed39e | |||
| 4c083c7314 | |||
| 3202640cf7 | |||
| c9bbe71869 | |||
| 00edb273cd | |||
| 608b3282dd | |||
| 2888bd5693 | |||
| 90d95da48d | |||
| 31fb52761e | |||
| f7e651d0bc | |||
| ff41fb37fd | |||
| 776c8ad3f7 | |||
| db97bf8654 | |||
| e2e0ffce44 | |||
| 2f3a3e0be4 | |||
| c5f7221fb2 | |||
| 4a42b947bc | |||
| 46b260641f | |||
| 60d80970a4 | |||
| daabd90359 | |||
| cb7f9cec2d | |||
| fe54733a39 | |||
| 8d6eff2792 | |||
| e4f3beee01 | |||
| 54b69f0d68 | |||
| c4b3df04a0 | |||
| d4c0da85da | |||
| 3fa376905c | |||
| a4a34e46a8 | |||
| d235dfa023 | |||
| 24898439cb | |||
| 6da98b4e7b | |||
| fba4b9b785 | |||
| b246a03ac4 | |||
| 04c64e97b0 | |||
| af9a55cebd | |||
| 44391d3d1d | |||
| 619c62d89a | |||
| dfc81923ba | |||
| 5a6205476a | |||
| be7f512244 | |||
| 5eec34c17e | |||
| 656214b162 | |||
| 45fc25d02b | |||
| 9aec5582db | |||
| 0f7e87a91c | |||
| 5acac804a1 | |||
| 85db90a2df | |||
| a026f2eddb | |||
| 73783b85d5 | |||
| 4cce4f6392 | |||
| 24e63a967a | |||
| dbb6ba333a | |||
| 97b48ca3dd | |||
| a4106ee20d | |||
| 21817543ad | |||
| 6babbc99e7 | |||
| 1f5e9d815b | |||
| 83caa48cdb | |||
| b51019a7e8 | |||
| 72d65cd7ae | |||
| 76251e935e | |||
| a58a14111b | |||
| 49230c3a44 | |||
| 262d2b2133 | |||
| ad2ff2ccc4 | |||
| dff7a58736 | |||
| 44c9bd645e | |||
| 4741d60e4c | |||
| 1a079a41e7 | |||
| ebbd3bcc61 | |||
| 54fdc8addc | |||
| e0320e761c | |||
| 2b7c308188 | |||
| 40ac52654f | |||
| 034e405824 | |||
| 20404cf3f2 | |||
| 264bb5475c | |||
| 6e3f9f6e79 | |||
| 9d0a993c2a | |||
| cd3e60ba4f | |||
| 360299f5f6 | |||
| d61e33113c | |||
| 5faf7cf45f | |||
| cd922fa750 | |||
| a4d4c386f7 | |||
| c8da826ef7 | |||
| 5166c2c4d7 | |||
| ec70e70a5d | |||
| 4a79b37714 | |||
| 76ae1c3603 | |||
| a60b88b80e | |||
| e31b4433a1 | |||
| 19183ad14a | |||
| e1412320a7 | |||
| b9c94dfab0 | |||
| 6f43c09bd0 | |||
| 9e15e95c2b | |||
| 1306c4cc9c | |||
| f1247817d3 | |||
| fdad5b85c9 | |||
| 39ee0b5973 | |||
| 33675c8ae8 | |||
| 90d5e9887a | |||
| c3af591810 | |||
| bb8a6200aa | |||
| 44573366eb | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 0b58707a49 | |||
| 0561b55af5 | |||
| d785ed9054 | |||
| 88fb8417fd | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| e28dcbff9a | |||
| 89ec0186a4 | |||
| 6e1efde8c6 | |||
| 6aa80d4210 | |||
| 4e86006b3f | |||
| 679e22a7c2 | |||
| 4d3228a4a8 | |||
| 0aa307f0b6 | |||
| 6a69ecefb1 | |||
| c05beb66e9 | |||
| 34ddb24014 | |||
| 9d69613df7 | |||
| 630f818538 | |||
| b280a720ff | |||
| 48bac9c212 | |||
| e88c49fb50 | |||
| 9e10a5a400 | |||
| 1dbea24fa2 | |||
| 0606228b40 | |||
| f35b9f0988 | |||
| c400c46672 | |||
| fbdeb2161d | |||
| 8c7d03dd29 | |||
| 135ce7b2df | |||
| 0e47ae051b | |||
| 04255473d2 | |||
| ce6bbff389 | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 | |||
| d79e9090e6 | |||
| 97fd660e38 | |||
| 96e168d035 | |||
| 4d2b77ecaf | |||
| e48da80a4b | |||
| 6125312f61 | |||
| 007fd0c2e3 | |||
| c4f90d6a57 | |||
| 5dd62c9466 | |||
| 4d072d7217 | |||
| b4242b1394 | |||
| fa2343dff9 | |||
| 1b1667bc2b | |||
| c2b4bf9c67 | |||
| 0845fefe6c | |||
| d911556a84 | |||
| 38be8d9401 | |||
| 9f3190f62a | |||
| 41aeb7e0f2 | |||
| f8e67519e1 | |||
| 4279dcba1e | |||
| be7e3d6b56 | |||
| 41e128190b | |||
| ba869ccde5 | |||
| 27fe066b23 | |||
| e94b8ff714 | |||
| e3a6894904 | |||
| 92b97bda00 | |||
| d5c6039296 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 86d13a7240 | |||
| 0b2d449ffa | |||
| d881373dce | |||
| 9ade4c65f3 | |||
| 5c73b85f65 |
@@ -0,0 +1,89 @@
|
|||||||
|
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') }}
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# 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"
|
||||||
@@ -7,26 +7,159 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump-version:
|
bump-version:
|
||||||
if: "!startsWith(github.event.head_commit.message, 'bump:')"
|
if: "!startsWith(github.event.head_commit.message, 'bump:') && !startsWith(github.event.head_commit.message, 'chore(release):')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Bump version and create changelog with commitizen"
|
name: "Bump version and create changelog for monorepo components"
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
- name: Create bump and changelog
|
|
||||||
uses: commitizen-tools/commitizen-action@bb4f1df6601e2a1a891506581b0c53acdc88e07d # 0.26.0
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
python-version: '3.11'
|
||||||
changelog_increment_filename: body.md
|
|
||||||
- name: Release
|
- name: Install uv
|
||||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
run: |
|
||||||
with:
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
body_path: "body.md"
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
tag_name: v${{ env.REVISION }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
- name: Configure git
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||||
|
|
||||||
|
- name: Detect and bump component versions
|
||||||
|
id: bump
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Track which components were bumped
|
||||||
|
BUMPED_COMPONENTS=""
|
||||||
|
|
||||||
|
# Helper function to check for commits with specific scope since last tag
|
||||||
|
has_commits_since_tag() {
|
||||||
|
local tag_pattern="$1"
|
||||||
|
local scope_pattern="$2"
|
||||||
|
|
||||||
|
# Get the most recent tag matching the pattern
|
||||||
|
local last_tag=$(git tag --sort=-creatordate | grep -E "^${tag_pattern}" | head -n 1 || echo "")
|
||||||
|
|
||||||
|
if [ -z "$last_tag" ]; then
|
||||||
|
# No previous tag, check all commits on master
|
||||||
|
local commit_range="master"
|
||||||
|
else
|
||||||
|
# Check commits since last tag
|
||||||
|
local commit_range="${last_tag}..HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count commits matching the scope pattern
|
||||||
|
local commit_count=$(git log "$commit_range" --oneline --grep="^${scope_pattern}" -E | wc -l)
|
||||||
|
|
||||||
|
if [ "$commit_count" -gt 0 ]; then
|
||||||
|
echo "Found $commit_count commits for scope '$scope_pattern' since $last_tag"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "No commits found for scope '$scope_pattern' since $last_tag"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Bump MCP server (default - all commits except helm/astrolabe scopes)
|
||||||
|
echo "Checking MCP server for version bump..."
|
||||||
|
|
||||||
|
# Get the most recent MCP tag
|
||||||
|
last_mcp_tag=$(git tag --sort=-creatordate | grep -E "^v[0-9]" | head -n 1 || echo "")
|
||||||
|
|
||||||
|
if [ -z "$last_mcp_tag" ]; then
|
||||||
|
commit_range="master"
|
||||||
|
else
|
||||||
|
commit_range="${last_mcp_tag}..HEAD"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Count conventional commits that are NOT scoped to helm or astrolabe
|
||||||
|
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)
|
||||||
|
|
||||||
|
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||||
|
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
|
||||||
|
echo "Bumping MCP server version..."
|
||||||
|
./scripts/bump-mcp.sh
|
||||||
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
|
||||||
|
else
|
||||||
|
echo "No commits found for MCP server since $last_mcp_tag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bump Helm chart (scope: helm)
|
||||||
|
echo "Checking Helm chart for version bump..."
|
||||||
|
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
|
||||||
|
echo "Bumping Helm chart version..."
|
||||||
|
./scripts/bump-helm.sh
|
||||||
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bump Astrolabe (scope: astrolabe)
|
||||||
|
echo "Checking Astrolabe for version bump..."
|
||||||
|
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
|
||||||
|
echo "Bumping Astrolabe version..."
|
||||||
|
./scripts/bump-astrolabe.sh
|
||||||
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Output summary
|
||||||
|
if [ -z "$BUMPED_COMPONENTS" ]; then
|
||||||
|
echo "No components required version bumps"
|
||||||
|
echo "bumped=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "Bumped components:$BUMPED_COMPONENTS"
|
||||||
|
echo "bumped=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "components=$BUMPED_COMPONENTS" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Push tags
|
||||||
|
if: steps.bump.outputs.bumped == 'true'
|
||||||
|
run: |
|
||||||
|
git push
|
||||||
|
git push --tags
|
||||||
|
echo "Pushed tags for components:${{ steps.bump.outputs.components }}"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.bump.outputs.bumped }}" == "true" ]; then
|
||||||
|
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for component in ${{ steps.bump.outputs.components }}; do
|
||||||
|
case $component in
|
||||||
|
mcp)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
|
||||||
|
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
helm)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||||
|
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
astrolabe)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||||
|
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✅ No version bumps required - no relevant commits found since last release." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The workflow completed successfully with no changes." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
prompt: |
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
|
||||||
|
Please review this pull request and provide feedback on:
|
||||||
|
- Code quality and best practices
|
||||||
|
- Potential bugs or issues
|
||||||
|
- Performance considerations
|
||||||
|
- Security concerns
|
||||||
|
- Test coverage
|
||||||
|
|
||||||
|
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||||
|
|
||||||
|
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||||
|
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||||
|
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||||
|
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||||
|
|
||||||
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||||
|
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||||
|
|
||||||
@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["*"]
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -12,11 +13,11 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
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: |
|
||||||
@@ -33,7 +34,7 @@ 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@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
|
|
||||||
- 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'
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
@@ -38,6 +38,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run chart-releaser
|
- name: Run chart-releaser
|
||||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||||
|
with:
|
||||||
|
skip_existing: true
|
||||||
env:
|
env:
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
|||||||
@@ -24,39 +24,25 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
|
||||||
submodules: 'true'
|
|
||||||
|
|
||||||
###### Required to build OIDC App ######
|
|
||||||
- name: Set up php 8.4
|
|
||||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
|
||||||
with:
|
|
||||||
php-version: 8.4
|
|
||||||
coverage: none
|
|
||||||
|
|
||||||
- name: Install OIDC app composer dependencies
|
|
||||||
run: |
|
|
||||||
cd third_party/oidc
|
|
||||||
composer install --no-dev
|
|
||||||
###### Required to build OIDC App ######
|
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: |
|
||||||
|
./docker-compose.yml
|
||||||
|
./docker-compose.ci.yml
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
env:
|
env:
|
||||||
# Override MCP container environment for OpenAI + vector sync
|
# Environment variables passed to docker-compose.ci.yml
|
||||||
VECTOR_SYNC_ENABLED: "true"
|
|
||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
|
||||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -101,11 +87,17 @@ jobs:
|
|||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
run: |
|
run: |
|
||||||
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
|
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||||
|
|
||||||
|
- name: Capture MCP container logs
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "=== MCP Container Logs ==="
|
||||||
|
docker compose logs mcp --tail=500
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||||
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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
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@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
@@ -48,15 +48,32 @@ 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@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
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@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+219
@@ -1,3 +1,222 @@
|
|||||||
|
# Changelog - MCP Server
|
||||||
|
|
||||||
|
All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.57.0 (2025-12-20)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **auth**: add multi-user BasicAuth pass-through mode
|
||||||
|
- **astrolabe**: add dynamic MCP server configuration for testing
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **config**: address reviewer feedback
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **config**: centralize configuration validation and simplify startup
|
||||||
|
|
||||||
|
## v0.56.2 (2025-12-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: screenshots in info.xml
|
||||||
|
- **astrolabe**: screenshots in info.xml
|
||||||
|
|
||||||
|
## v0.56.1 (2025-12-19)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: Update screenshots
|
||||||
|
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
||||||
|
|
||||||
|
## v0.56.0 (2025-12-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **ci**: add --increment flag to bump scripts for manual version control
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: add contents:write permission to appstore workflow
|
||||||
|
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
||||||
|
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
||||||
|
- **astrolabe**: info.xml
|
||||||
|
|
||||||
|
## v0.55.1 (2025-12-19)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: push all tags explicitly in bump workflow
|
||||||
|
|
||||||
|
## v0.55.0 (2025-12-19)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
- MCP server now bumps for ANY conventional commit except
|
||||||
|
those explicitly scoped to helm or astrolabe.
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **ci**: implement monorepo-aware version bumping workflow
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: make MCP server default bump target for all non-scoped commits
|
||||||
|
- **ci**: restrict docker build to MCP server tags only
|
||||||
|
- **ci**: correct appstore-push-action version to v1.0.4
|
||||||
|
|
||||||
|
## v0.54.0 (2025-12-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **astrolabe**: add Nextcloud App Store deployment automation
|
||||||
|
- configure commitizen monorepo with independent versioning
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: improve versioning and error handling
|
||||||
|
- **ci**: address critical workflow and validation issues
|
||||||
|
- **astrolabe**: address code review feedback
|
||||||
|
|
||||||
|
## v0.53.0 (2025-12-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add Alembic database migration system
|
||||||
|
- make chunk modal title clickable link to documents
|
||||||
|
- add native Plotly hover styling for clickable points
|
||||||
|
- add click interactivity to Plotly 3D scatter chart
|
||||||
|
- improve chunk viewer with fixed navigation and markdown rendering
|
||||||
|
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
||||||
|
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
||||||
|
- **astrolabe**: enhance unified search and add webhook management
|
||||||
|
- **astrolabe**: add webhook management UI to admin settings
|
||||||
|
- **astrolabe**: add OAuth token refresh and webhook presets
|
||||||
|
- **search**: add file_path metadata and chunk offsets to search results
|
||||||
|
- **astrolabe**: use proper icons and thumbnails in unified search
|
||||||
|
- **astrolabe**: add admin search settings and enhanced UI
|
||||||
|
- **astrolabe**: add unified search provider with clickable file links
|
||||||
|
- **astrolabe**: add 3D PCA visualization for semantic search
|
||||||
|
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
||||||
|
- **vector-sync**: enable background sync in OAuth mode
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **security**: address critical security issues from PR #401 code review
|
||||||
|
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
||||||
|
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
||||||
|
- resolve type checking warnings for CI
|
||||||
|
- move Alembic to package submodule for Docker compatibility
|
||||||
|
- update unified search results to match chunk viz display
|
||||||
|
- **astrolabe**: handle OAuth refresh token rotation
|
||||||
|
- address critical code review issues (4 fixes)
|
||||||
|
- resolve CI linting issues for Astroglobe
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **astrolabe**: extract PDF viewer to dedicated component
|
||||||
|
- **astrolabe**: reframe UI as semantic search service
|
||||||
|
|
||||||
|
## v0.52.1 (2025-12-13)
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
||||||
|
|
||||||
|
## v0.52.0 (2025-12-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **vector**: add Deck card vector search with visualization support
|
||||||
|
|
||||||
|
## v0.51.0 (2025-12-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **vector-viz**: add news_item support for links and chunk expansion
|
||||||
|
|
||||||
|
## v0.50.2 (2025-12-13)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **news**: revert get_item() to use get_items() + filter
|
||||||
|
|
||||||
|
## v0.50.1 (2025-12-12)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Disable DNS rebinding protection for containerized deployments
|
||||||
|
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||||
|
|
||||||
|
## v0.50.0 (2025-12-11)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add MCP tool annotations for enhanced UX
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR review feedback
|
||||||
|
|
||||||
|
## v0.49.2 (2025-12-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Update lockfile
|
||||||
|
|
||||||
|
## v0.49.1 (2025-12-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Revert mcp version <1.23
|
||||||
|
|
||||||
|
## v0.49.0 (2025-12-08)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **news**: add Nextcloud News app integration
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- resolve all type checking errors (8 errors fixed)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **news**: simplify vector sync to fetch all items
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **news**: use direct API endpoint for get_item()
|
||||||
|
|
||||||
|
## v0.48.6 (2025-12-03)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||||
|
|
||||||
|
## v0.48.5 (2025-11-28)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency pillow to v12
|
||||||
|
|
||||||
|
## v0.48.4 (2025-11-23)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add rate limit retry logic to OpenAI provider
|
||||||
|
|
||||||
|
## v0.48.3 (2025-11-23)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||||
|
|
||||||
## v0.48.2 (2025-11-23)
|
## v0.48.2 (2025-11-23)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -56,6 +56,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
||||||
|
|
||||||
|
### MCP Tool Annotations (ADR-017)
|
||||||
|
|
||||||
|
**All tools MUST include annotations** following these patterns:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
# Read-only tools (list, search, get)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Human Readable Name",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True, # Nextcloud is external to MCP server
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create operations
|
||||||
|
@mcp.tool(
|
||||||
|
title="Create Resource",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Creates new resources each time
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update operations (with etag/version control)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Update Resource",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # ETag changes = different inputs
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete operations
|
||||||
|
@mcp.tool(
|
||||||
|
title="Delete Resource",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Permanently deletes data
|
||||||
|
idempotentHint=True, # Same end state if called repeatedly
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# HTTP PUT without version control (special case)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Write File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=True, # Same content = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key Principles**:
|
||||||
|
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
|
||||||
|
- **Destructive**: Operations that permanently delete/overwrite data
|
||||||
|
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
|
||||||
|
- **Titles**: Use human-readable names, not snake_case function names
|
||||||
|
|
||||||
|
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
|
||||||
|
|
||||||
### Project Structure
|
### Project Structure
|
||||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||||
@@ -444,6 +506,29 @@ docker compose exec app php occ user_oidc:provider keycloak
|
|||||||
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
||||||
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
|
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
|
||||||
|
|
||||||
|
### Querying Nextcloud Application Logs
|
||||||
|
|
||||||
|
**Use this pattern** to inspect Nextcloud application logs during debugging:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View recent log entries
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | jq | tail
|
||||||
|
|
||||||
|
# Filter by app
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.app == "astrolabe")' | tail
|
||||||
|
|
||||||
|
# Filter by log level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=FATAL)
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.level >= 3)' | tail
|
||||||
|
|
||||||
|
# Search for specific messages
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.message | contains("OAuth"))' | tail -20
|
||||||
|
|
||||||
|
# View full exception traces
|
||||||
|
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.exception != null)' | tail -5
|
||||||
|
```
|
||||||
|
|
||||||
|
**Log Structure**: Each entry is a JSON object with fields: `reqId`, `level`, `time`, `remoteAddr`, `user`, `app`, `method`, `url`, `message`, `userAgent`, `version`, `exception`
|
||||||
|
|
||||||
**For detailed setup, see**:
|
**For detailed setup, see**:
|
||||||
- `docs/installation.md` - Installation guide
|
- `docs/installation.md` - Installation guide
|
||||||
- `docs/configuration.md` - Configuration options
|
- `docs/configuration.md` - Configuration options
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
# Contributing to Nextcloud MCP Server
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | Scope | Bump Command | Tag Example |
|
||||||
|
|-----------|-------|--------------|-------------|
|
||||||
|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||||
|
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||||
|
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
Use conventional commits with **scopes** to target specific components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MCP server changes
|
||||||
|
feat(mcp): add calendar sync API
|
||||||
|
fix(mcp): resolve authentication bug
|
||||||
|
|
||||||
|
# Helm chart changes
|
||||||
|
feat(helm): add resource limits
|
||||||
|
docs(helm): update values documentation
|
||||||
|
|
||||||
|
# Astrolabe app changes
|
||||||
|
feat(astrolabe): add dark mode toggle
|
||||||
|
fix(astrolabe): resolve search UI bug
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unscoped commits** default to the MCP server:
|
||||||
|
```bash
|
||||||
|
feat: add new feature # → MCP server (v0.54.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Workflow
|
||||||
|
|
||||||
|
#### 1. Make Changes with Scoped Commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||||
|
git commit -m "feat(helm): add ingress annotations"
|
||||||
|
git commit -m "feat(mcp): add calendar sync"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Bump Component Versions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bump MCP server (reads commits with scope=mcp or unscoped)
|
||||||
|
./scripts/bump-mcp.sh
|
||||||
|
# → Creates tag: v0.54.0
|
||||||
|
# → Updates: pyproject.toml, Chart.yaml:appVersion
|
||||||
|
|
||||||
|
# Bump Helm chart (reads commits with scope=helm)
|
||||||
|
./scripts/bump-helm.sh
|
||||||
|
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||||
|
# → Updates: Chart.yaml:version
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --follow-tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changelog Filtering
|
||||||
|
|
||||||
|
Each component maintains its own `CHANGELOG.md`:
|
||||||
|
|
||||||
|
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||||
|
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||||
|
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||||
|
|
||||||
|
### Manual Version Bumps
|
||||||
|
|
||||||
|
For specific increments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch bump (0.53.0 → 0.53.1)
|
||||||
|
uv run cz bump --increment PATCH
|
||||||
|
|
||||||
|
# Minor bump (0.53.0 → 0.54.0)
|
||||||
|
uv run cz bump --increment MINOR
|
||||||
|
|
||||||
|
# Major bump (0.53.0 → 1.0.0)
|
||||||
|
uv run cz bump --increment MAJOR
|
||||||
|
|
||||||
|
# For non-MCP components, use --config
|
||||||
|
cd charts/nextcloud-mcp-server
|
||||||
|
uv run cz --config .cz.toml bump --increment MINOR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning Philosophy
|
||||||
|
|
||||||
|
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||||
|
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||||
|
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||||
|
|
||||||
|
### Chart.yaml Version vs appVersion
|
||||||
|
|
||||||
|
The Helm chart has TWO version fields:
|
||||||
|
|
||||||
|
- **`version`**: Chart packaging version (bumped by `feat(helm):`)
|
||||||
|
- Example: `0.53.0` → `0.54.0` when adding resource limits
|
||||||
|
|
||||||
|
- **`appVersion`**: MCP server version being deployed (bumped by `feat(mcp):`)
|
||||||
|
- Example: `"0.53.0"` → `"0.54.0"` when MCP server releases
|
||||||
|
|
||||||
|
This allows the chart to evolve independently from the application.
|
||||||
+8
-4
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
@@ -12,13 +12,17 @@ RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock README.md .
|
||||||
|
|
||||||
|
RUN uv sync --locked --no-dev --no-install-project --no-cache
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN uv sync --locked --no-dev --no-editable --no-cache
|
RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV VIRTUAL_ENV=/app/.venv
|
ENV VIRTUAL_ENV=/app/.venv
|
||||||
ENV PATH=/app/.vnev/bin:$PATH
|
ENV PATH=/app/.venv/bin:$PATH
|
||||||
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
||||||
|
|
||||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "run", "--host", "0.0.0.0"]
|
||||||
|
|||||||
+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:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
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.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ http://127.0.0.1:8000/mcp
|
|||||||
|
|
||||||
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
||||||
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
||||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
|
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
|
||||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||||
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
||||||
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
||||||
@@ -81,7 +81,7 @@ http://127.0.0.1:8000/mcp
|
|||||||
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
||||||
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
||||||
| **Sharing** | 10+ | Create and manage shares |
|
| **Sharing** | 10+ | Create and manage shares |
|
||||||
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
|
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
|
||||||
|
|
||||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||||
|
|
||||||
@@ -127,7 +127,7 @@ This enables natural language queries and helps discover related content across
|
|||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> **Semantic Search is experimental and opt-in:**
|
> **Semantic Search is experimental and opt-in:**
|
||||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
> - Disabled by default (`ENABLE_SEMANTIC_SEARCH=false`)
|
||||||
> - Currently supports Notes app only (multi-app support planned)
|
> - Currently supports Notes app only (multi-app support planned)
|
||||||
> - Requires additional infrastructure: vector database + embedding service
|
> - Requires additional infrastructure: vector database + embedding service
|
||||||
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
|
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
|
||||||
@@ -145,7 +145,7 @@ This enables natural language queries and helps discover related content across
|
|||||||
### Features
|
### Features
|
||||||
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
||||||
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
||||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
|
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
|
||||||
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
||||||
|
|
||||||
### Advanced Topics
|
### Advanced Topics
|
||||||
|
|||||||
+90
@@ -0,0 +1,90 @@
|
|||||||
|
# Alembic configuration file for nextcloud-mcp-server
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
# Path to migration scripts
|
||||||
|
script_location = nextcloud_mcp_server/alembic
|
||||||
|
|
||||||
|
# Template used to generate migration file names
|
||||||
|
# Default: %%(rev)s_%%(slug)s
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
|
||||||
|
# Timezone for migration timestamps
|
||||||
|
# Default: utc
|
||||||
|
timezone = utc
|
||||||
|
|
||||||
|
# Max length of characters to apply to the "slug" field
|
||||||
|
# Default: 40
|
||||||
|
# truncate_slug_length = 40
|
||||||
|
|
||||||
|
# Set to 'true' to run the environment during the 'revision' command
|
||||||
|
# Default: false
|
||||||
|
# revision_environment = false
|
||||||
|
|
||||||
|
# Set to 'true' to allow .pyc and .pyo files without a source .py file
|
||||||
|
# Default: false
|
||||||
|
# sourceless = false
|
||||||
|
|
||||||
|
# Version location specification
|
||||||
|
# Supports single or multiple directories
|
||||||
|
version_locations = nextcloud_mcp_server/alembic/versions
|
||||||
|
|
||||||
|
# Path separator for version locations (required to suppress deprecation warning)
|
||||||
|
# Use os (for cross-platform compatibility)
|
||||||
|
path_separator = os
|
||||||
|
|
||||||
|
# Set to 'true' to search source files recursively in each "version_locations" directory
|
||||||
|
# Default: false
|
||||||
|
# recursive_version_locations = false
|
||||||
|
|
||||||
|
# Output encoding used when revision files are written
|
||||||
|
# Default: utf-8
|
||||||
|
# output_encoding = utf-8
|
||||||
|
|
||||||
|
# Database URL - can be overridden by:
|
||||||
|
# 1. Passing -x database_url=... to alembic commands
|
||||||
|
# 2. Setting in environment via get_database_url() in env.py
|
||||||
|
# Default: sqlite:///app/data/tokens.db
|
||||||
|
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
# Post-write hooks allow you to run scripts after generating migration files
|
||||||
|
# Example: format migrations with ruff
|
||||||
|
# hooks = ruff
|
||||||
|
# ruff.type = exec
|
||||||
|
# ruff.executable = ruff
|
||||||
|
# ruff.options = format REVISION_SCRIPT_FILENAME
|
||||||
|
|
||||||
|
# Logging configuration
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
Database Migrations for nextcloud-mcp-server
|
||||||
|
============================================
|
||||||
|
|
||||||
|
This directory contains Alembic database migrations for the token storage database.
|
||||||
|
|
||||||
|
Structure
|
||||||
|
---------
|
||||||
|
- env.py: Alembic environment configuration
|
||||||
|
- script.py.mako: Template for generating new migration files
|
||||||
|
- versions/: Directory containing migration scripts
|
||||||
|
|
||||||
|
Usage
|
||||||
|
-----
|
||||||
|
Migrations are managed via the CLI:
|
||||||
|
|
||||||
|
# Upgrade database to latest version
|
||||||
|
uv run nextcloud-mcp-server db upgrade
|
||||||
|
|
||||||
|
# Show current database version
|
||||||
|
uv run nextcloud-mcp-server db current
|
||||||
|
|
||||||
|
# Show migration history
|
||||||
|
uv run nextcloud-mcp-server db history
|
||||||
|
|
||||||
|
# Create a new migration (developers only)
|
||||||
|
uv run nextcloud-mcp-server db migrate "description of changes"
|
||||||
|
|
||||||
|
# Downgrade database by one version (emergency use only)
|
||||||
|
uv run nextcloud-mcp-server db downgrade
|
||||||
|
|
||||||
|
Direct Alembic Usage
|
||||||
|
--------------------
|
||||||
|
You can also use Alembic commands directly:
|
||||||
|
|
||||||
|
# Specify database URL via -x flag
|
||||||
|
uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head
|
||||||
|
|
||||||
|
# Or set in alembic.ini and run
|
||||||
|
uv run alembic upgrade head
|
||||||
|
uv run alembic current
|
||||||
|
uv run alembic history
|
||||||
|
|
||||||
|
Writing Migrations
|
||||||
|
------------------
|
||||||
|
Since we don't use SQLAlchemy models, migrations are written with raw SQL:
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
ALTER TABLE refresh_tokens
|
||||||
|
ADD COLUMN new_field TEXT
|
||||||
|
""")
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# SQLite doesn't support DROP COLUMN, use table recreation
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE refresh_tokens_new AS
|
||||||
|
SELECT user_id, encrypted_token, ... FROM refresh_tokens
|
||||||
|
""")
|
||||||
|
op.execute("DROP TABLE refresh_tokens")
|
||||||
|
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
|
||||||
|
|
||||||
|
Migration File Naming
|
||||||
|
---------------------
|
||||||
|
Format: YYYYMMDD_HHMM_<revision>_<slug>.py
|
||||||
|
Example: 20251217_2200_001_initial_schema.py
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
- Migrations run automatically when RefreshTokenStorage.initialize() is called
|
||||||
|
- Existing databases are automatically stamped with the initial version
|
||||||
|
- SQLite has limited ALTER TABLE support - complex changes require table recreation
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = ${repr(up_revision)}
|
||||||
|
down_revision = ${repr(down_revision)}
|
||||||
|
branch_labels = ${repr(branch_labels)}
|
||||||
|
depends_on = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Apply migration changes to upgrade the database schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Revert migration changes to downgrade the database schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -3,3 +3,9 @@
|
|||||||
set -euox pipefail
|
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)
|
||||||
|
# These ensure that URLs generated by Nextcloud include the correct host:port
|
||||||
|
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
|
||||||
|
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
|
||||||
|
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable news
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
echo "Installing Astrolabe app for testing..."
|
||||||
|
|
||||||
|
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||||
|
if [ -d /opt/apps/astrolabe ]; then
|
||||||
|
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
||||||
|
|
||||||
|
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
||||||
|
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
||||||
|
echo "Removing existing astrolabe in custom_apps..."
|
||||||
|
rm -rf /var/www/html/custom_apps/astrolabe
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create symlink from custom_apps to the mounted development version
|
||||||
|
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||||
|
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
||||||
|
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
||||||
|
|
||||||
|
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
||||||
|
php /var/www/html/occ app:enable astrolabe
|
||||||
|
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||||
|
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||||
|
php /var/www/html/occ app:enable astrolabe
|
||||||
|
else
|
||||||
|
echo "astrolabe app not found, installing from app store..."
|
||||||
|
php /var/www/html/occ app:install astrolabe
|
||||||
|
php /var/www/html/occ app:enable astrolabe
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Astrolabe app installed successfully"
|
||||||
|
echo ""
|
||||||
|
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||||
|
echo " to support testing multiple MCP server deployments."
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
[tool.commitizen]
|
||||||
|
name = "cz_conventional_commits"
|
||||||
|
version = "0.54.0"
|
||||||
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
|
version_scheme = "semver"
|
||||||
|
update_changelog_on_bump = true
|
||||||
|
major_version_zero = true
|
||||||
|
|
||||||
|
# Update chart version only (NOT appVersion)
|
||||||
|
version_files = [
|
||||||
|
"Chart.yaml:^version:"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Ignore tags from other components
|
||||||
|
ignored_tag_formats = [
|
||||||
|
"v*", # MCP server tags
|
||||||
|
"astrolabe-v*", # Astrolabe tags
|
||||||
|
]
|
||||||
|
|
||||||
|
# Filter commits by scope
|
||||||
|
# Includes helm-scoped commits AND MCP server version bumps (which update appVersion)
|
||||||
|
[tool.commitizen.customize]
|
||||||
|
changelog_pattern = "^((feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:|bump: version.*→.*)"
|
||||||
|
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
|
||||||
|
message_template = "{{change_type}}(helm): {{message}}"
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
# Changelog - Helm Chart
|
||||||
|
|
||||||
|
All notable changes to the Helm chart will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial independent versioning release
|
||||||
|
- Support for Nextcloud MCP server deployment
|
||||||
|
- Qdrant subchart integration
|
||||||
|
- Ollama subchart integration
|
||||||
|
- Configurable resource limits
|
||||||
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.54.0 (2025-12-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **ci**: implement monorepo-aware version bumping workflow
|
||||||
|
- **astrolabe**: add Nextcloud App Store deployment automation
|
||||||
|
- configure commitizen monorepo with independent versioning
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: improve versioning and error handling
|
||||||
|
- **ci**: address critical workflow and validation issues
|
||||||
|
- **astrolabe**: address code review feedback
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.53.0 (2025-12-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add Alembic database migration system
|
||||||
|
- make chunk modal title clickable link to documents
|
||||||
|
- add native Plotly hover styling for clickable points
|
||||||
|
- add click interactivity to Plotly 3D scatter chart
|
||||||
|
- improve chunk viewer with fixed navigation and markdown rendering
|
||||||
|
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
||||||
|
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
||||||
|
- **astrolabe**: enhance unified search and add webhook management
|
||||||
|
- **astrolabe**: add webhook management UI to admin settings
|
||||||
|
- **astrolabe**: add OAuth token refresh and webhook presets
|
||||||
|
- **search**: add file_path metadata and chunk offsets to search results
|
||||||
|
- **astrolabe**: use proper icons and thumbnails in unified search
|
||||||
|
- **astrolabe**: add admin search settings and enhanced UI
|
||||||
|
- **astrolabe**: add unified search provider with clickable file links
|
||||||
|
- **astrolabe**: add 3D PCA visualization for semantic search
|
||||||
|
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
||||||
|
- **vector-sync**: enable background sync in OAuth mode
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **security**: address critical security issues from PR #401 code review
|
||||||
|
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
||||||
|
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
||||||
|
- resolve type checking warnings for CI
|
||||||
|
- move Alembic to package submodule for Docker compatibility
|
||||||
|
- update unified search results to match chunk viz display
|
||||||
|
- **astrolabe**: handle OAuth refresh token rotation
|
||||||
|
- address critical code review issues (4 fixes)
|
||||||
|
- resolve CI linting issues for Astroglobe
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **astrolabe**: extract PDF viewer to dedicated component
|
||||||
|
- **astrolabe**: reframe UI as semantic search service
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.52.1 (2025-12-13)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.52.0 (2025-12-13)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.51.0 (2025-12-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **vector**: add Deck card vector search with visualization support
|
||||||
|
- **vector-viz**: add news_item support for links and chunk expansion
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.50.2 (2025-12-13)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **news**: revert get_item() to use get_items() + filter
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.50.1 (2025-12-12)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Disable DNS rebinding protection for containerized deployments
|
||||||
|
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.50.0 (2025-12-11)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add MCP tool annotations for enhanced UX
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR review feedback
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.49.2 (2025-12-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Update lockfile
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.49.1 (2025-12-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Revert mcp version <1.23
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.49.0 (2025-12-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- resolve all type checking errors (8 errors fixed)
|
||||||
|
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **news**: use direct API endpoint for get_item()
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.5 (2025-11-28)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **news**: add Nextcloud News app integration
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency pillow to v12
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- **news**: simplify vector sync to fetch all items
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.4 (2025-11-23)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add rate limit retry logic to OpenAI provider
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.3 (2025-11-23)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.2 (2025-11-23)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.1 (2025-11-23)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.48.0 (2025-11-23)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.47.0 (2025-11-23)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add tag management methods to WebDAV client
|
||||||
|
- Add OpenAI provider support for embeddings and generation
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||||
|
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Move background tasks to server lifespan and deprecate SSE transport
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.46.2 (2025-11-22)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **smithery**: Enable JSON response format for scanner compatibility
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.46.1 (2025-11-22)
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- Optimize vector viz search performance
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.46.0 (2025-11-22)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add Smithery CLI deployment support
|
||||||
|
- Implement ADR-016 Smithery stateless deployment mode
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||||
|
- **smithery**: Use container runtime pattern for config discovery
|
||||||
|
- Add Smithery lifespan and auth mode detection
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.45.0 (2025-11-22)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add context expansion to semantic search with chunk overlap removal
|
||||||
|
- Use Ollama native batch API in embed_batch()
|
||||||
|
- Implement Qdrant placeholder state management
|
||||||
|
- Switch files to use numeric IDs with file_path resolution
|
||||||
|
- Implement per-chunk vector visualization with context expansion
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use alpha_composite for proper RGBA highlight blending
|
||||||
|
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
||||||
|
- Centralize PDF processing and generate separate images per chunk
|
||||||
|
- Set is_placeholder=False in processor to fix search filtering
|
||||||
|
- Increase placeholder staleness threshold to 5x scan interval
|
||||||
|
- Add placeholder staleness check to prevent duplicate processing
|
||||||
|
- Use empty SparseVector instead of None for placeholders
|
||||||
|
- Return empty array instead of null for query_coords when no results
|
||||||
|
- Align PDF text extraction between indexing and context expansion
|
||||||
|
- Update models and viz to use int-only doc_id
|
||||||
|
- Reconstruct full content for notes to match indexed offsets
|
||||||
|
- Add async/await, PDF metadata, and type safety fixes
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Simplify PDF text extraction with single to_markdown call
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- Optimize PDF processing with parallel extraction and single-render highlights
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.44.1 (2025-11-21)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.22,<1.23
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.44.0 (2025-11-19)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Improve vector visualization with static assets and fixes
|
||||||
|
- Redesign UI to match Nextcloud ecosystem aesthetic
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Improve 3D plot rendering with explicit dimensions and window resize support
|
||||||
|
- Preserve 3D plot camera and improve documentation
|
||||||
|
- Preserve 3D plot camera position and fix CSS loading
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.43.0 (2025-11-18)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.42.0 (2025-11-17)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **viz**: Add dual-score display and improve UI controls
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.41.0 (2025-11-17)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add configurable fusion algorithms for BM25 hybrid search
|
||||||
|
- add chunk position tracking to vector indexing and search
|
||||||
|
- add vector viz template and chunk context endpoint
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- prevent infinite loop in DocumentChunker with position tracking
|
||||||
|
- Relax SearchResult validation to support DBSF fusion scores > 1.0
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.40.0 (2025-11-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add unified provider architecture with Amazon Bedrock support
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- suppress Starlette middleware type warnings in ty checker
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.39.0 (2025-11-16)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.38.0 (2025-11-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add concurrent uploads and --force flag to upload command
|
||||||
|
- implement RAG evaluation framework with CLI tooling
|
||||||
|
- Add OpenTelemetry tracing to @instrument_tool decorator
|
||||||
|
- Implement BM25 hybrid search with native Qdrant RRF fusion
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- download qrels from BEIR ZIP instead of HuggingFace
|
||||||
|
- Handle named vectors in visualization and semantic search
|
||||||
|
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
|
||||||
|
- Update viz routes to use BM25 hybrid search after refactor
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- migrate asyncio to anyio for consistent structured concurrency
|
||||||
|
- replace httpx client with NextcloudClient in upload command
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- Eliminate double-fetching in semantic search sampling
|
||||||
|
- fix vector viz search performance and visual encoding
|
||||||
|
- make note deletion concurrent in upload --force
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.36.0 (2025-11-15)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
- Search algorithms now require Qdrant to be populated.
|
||||||
|
Vector sync must be enabled and documents indexed for search to work.
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Normalize hybrid search RRF scores to 0-1 range
|
||||||
|
- Enhance vector visualization UI and parallelize search verification
|
||||||
|
- Add Vector Viz tab to app home page
|
||||||
|
- Add vector visualization pane with multi-select document types
|
||||||
|
- Implement custom PCA to remove sklearn dependency
|
||||||
|
- Add multi-document Protocol with cross-app search support
|
||||||
|
- Update nc_semantic_search tool with algorithm selection
|
||||||
|
- Implement unified search algorithm module
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Reorder tabs and fix viz pane session access
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Optimize Nextcloud access verification with centralized filtering
|
||||||
|
- Make all search algorithms query Qdrant payload, not Nextcloud
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- Exclude vector-sync status polling from distributed tracing
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.35.0 (2025-11-15)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Enable SSE transport for mcp service and update test fixtures
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.34.2 (2025-11-13)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
|
||||||
|
- return all notes when search query is empty
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.34.0 (2025-11-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Complete Phase 5 - Instrument all 93 MCP tools
|
||||||
|
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
||||||
|
- Add OAuth token and database metrics (Phases 3-4)
|
||||||
|
- Add metrics instrumentation for queue, health, and database operations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.33.1 (2025-11-13)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Move grafana_folder from labels to annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.33.0 (2025-11-13)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add Grafana dashboard and vector sync metric instrumentation
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.32.1 (2025-11-12)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add dynamic dimension detection for Ollama embedding models
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.32.0 (2025-11-11)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **ollama**: Pull model on startup if not available in ollama
|
||||||
|
- add dynamic vector sync status updates with htmx polling
|
||||||
|
- add webhook management UI and BeforeNodeDeletedEvent support
|
||||||
|
- validate Nextcloud webhook schemas and document findings
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- improve webapp tab UI with CSS Grid and viewport-filling container
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- move webapp from /user/page to /app
|
||||||
|
- consolidate database storage for webhooks and OAuth tokens
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.31.1 (2025-11-10)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- simplify OpenTelemetry tracing configuration
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.31.0 (2025-11-10)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- skip tracing for health and metrics endpoints
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add retry logic for ETag conflicts in category change test
|
||||||
|
- optimize Notes API pagination with pruneBefore parameter
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.30.0 (2025-11-10)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **helm**: Add document chunking configuration
|
||||||
|
- **vector**: Add configurable chunk size and overlap for document embedding
|
||||||
|
- **vector**: Support multiple embedding models with auto-generated collection names
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Support in-memory Qdrant for CI testing
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.29.2 (2025-11-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Set default strategy to Recreate
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.29.1 (2025-11-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **observability**: isolate metrics endpoint to dedicated port
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.29.0 (2025-11-09)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **readiness**: Only check external Qdrant in network mode
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.28.0 (2025-11-09)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **vector**: Handle missing 'modified' field in notes gracefully
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.27.3 (2025-11-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: Use helm dependency build instead of update to use Chart.lock
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.27.2 (2025-11-09)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: update Qdrant dependency condition to match new mode structure
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.27.1 (2025-11-09)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
|
||||||
|
- add Qdrant local mode support with in-memory and persistent storage
|
||||||
|
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
||||||
|
- implement MCP sampling for semantic search RAG (ADR-008)
|
||||||
|
- add optional vector database and semantic search to helm chart
|
||||||
|
- add vector sync processing status to /user/page endpoint
|
||||||
|
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
||||||
|
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **ci**: add Helm repository setup to chart release workflow
|
||||||
|
- implement deletion grace period and vector sync status tool
|
||||||
|
- remove unnecessary urllib3<2.0 constraint
|
||||||
|
- integrate vector sync tasks with Starlette lifespan for streamable-http
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- migrate vector sync from asyncio.Queue to anyio memory object streams
|
||||||
|
- update to Qdrant query_points API and fix Playwright Keycloak login
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.26.1 (2025-11-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.21,<1.22
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.26.0 (2025-11-08)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add real elicitation integration test with python-sdk MCP client
|
||||||
|
- unify session architecture and enhance login status visibility
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Consolidate OAuth callbacks and implement PKCE for all flows
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.25.0 (2025-11-05)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
- All OAuth deployments must be reconfigured to specify
|
||||||
|
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
||||||
|
choose between multi-audience or token exchange mode.
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Implement proper OAuth resource parameters and PRM-based discovery
|
||||||
|
- Simplify token verifier to be RFC 7519 compliant
|
||||||
|
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
||||||
|
- Correct OAuth token audience validation for multi-audience mode
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.24.1 (2025-11-04)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.20,<1.21
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.24.0 (2025-11-04)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add scope protection to OAuth provisioning tools
|
||||||
|
- enable authorization services for token exchange in Keycloak
|
||||||
|
- implement scope-based audience mapping and RFC 9728 support
|
||||||
|
- integrate token exchange into MCP server application
|
||||||
|
- implement RFC 8693 Standard Token Exchange for Keycloak
|
||||||
|
- Add userinfo route/page
|
||||||
|
- add browser-based user info page with separate OAuth flow
|
||||||
|
- Implement ADR-004 Progressive Consent foundation (partial)
|
||||||
|
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
||||||
|
- Implement ADR-004 Progressive Consent foundation components
|
||||||
|
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add missing await for get_nextcloud_client in capabilities resource
|
||||||
|
- use valid Fernet encryption keys in token exchange tests
|
||||||
|
- accept resource URL in token audience for Nextcloud JWT tokens
|
||||||
|
- remove token-exchange-nextcloud scope and accept tokens without audience
|
||||||
|
- move audience mapper from scope to nextcloud-mcp-server client
|
||||||
|
- move token-exchange-nextcloud from default to optional scopes
|
||||||
|
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
||||||
|
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
||||||
|
- correct OAuth token audience validation using RFC 8707 resource parameter
|
||||||
|
- remove remaining references to deleted oauth_callback and oauth_token
|
||||||
|
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
||||||
|
- browser OAuth userinfo endpoint and refresh token rotation
|
||||||
|
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
||||||
|
- make provisioning checks opt-in (default false)
|
||||||
|
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- integrate token exchange into unified get_client() pattern
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.23.0 (2025-11-03)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Auto-configure impersonation role in Keycloak realm import
|
||||||
|
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
||||||
|
- Add Keycloak external IdP integration with custom scopes
|
||||||
|
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
||||||
|
- Add Keycloak OAuth provider support with refresh token storage
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Complete Keycloak external IdP integration with all tests passing
|
||||||
|
- Complete Keycloak external IdP integration with all tests passing
|
||||||
|
- Update DCR token_type tests for OIDC app changes
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
||||||
|
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
||||||
|
- Unify OAuth configuration to be provider-agnostic
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.22.7 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Remove image tag overide
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.22.6 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm chart with extraArgs
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.22.5 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Update helm chart variables
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.22.4 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.1.1 (2025-10-29)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: Update helm version with release
|
||||||
|
- Trigger release
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.1.0 (2025-10-29)
|
||||||
|
|
||||||
|
### BREAKING CHANGE
|
||||||
|
|
||||||
|
- FASTMCP_-prefixed env vars have been replaced by CLI
|
||||||
|
arguments. Refer to the README for updated usage.
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **server**: Add /live & /health endpoints
|
||||||
|
- Initialize helm chart
|
||||||
|
- Add text processing background worker for telling client about progress
|
||||||
|
- **auth**: Add support for client registration deletion
|
||||||
|
- Split read/write scopes into app:read/write scopes
|
||||||
|
- Enable token introspection for opaque tokens
|
||||||
|
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
||||||
|
- Initialize JWT-scoped tools
|
||||||
|
- **caldav**: Add support for tasks
|
||||||
|
- **webdav**: Add search and list favorite response tools
|
||||||
|
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
||||||
|
- Add Groups API client
|
||||||
|
- add sharing API client and server tools
|
||||||
|
- **server**: Experimental support for OAuth2/OIDC authentication
|
||||||
|
- **users**: Initialize user API client
|
||||||
|
- **server**: Add support for `streamable-http` transport type
|
||||||
|
- Add WebDAV resource copy functionality
|
||||||
|
- Add WebDAV resource move/rename functionality
|
||||||
|
- **deck**: Add support for stack, cards, labels
|
||||||
|
- **deck**: Initialize Deck app client/server
|
||||||
|
- **cli**: Replace `mcp run` with click CLI and runtime options
|
||||||
|
- **client**: Preserve fields when modifying contacts/calendar resources
|
||||||
|
- **server**: Add structured output to all tool/resource output
|
||||||
|
- **contacts**: Initialize Contacts App
|
||||||
|
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
||||||
|
- Update webdav client create_directory method to handle recursive directories
|
||||||
|
- **webdav**: add complete file system support
|
||||||
|
- Add TablesClient and associated tools
|
||||||
|
- Switch to using async client
|
||||||
|
- **notes**: Add append to note functionality
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- Add support for RFC 7592 client registration and deletion
|
||||||
|
- Update webdav models for proper serialization
|
||||||
|
- **deps**: update dependency mcp to >=1.19,<1.20
|
||||||
|
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||||
|
- Use occ-created OAuth clients with allowed_scopes for all tests
|
||||||
|
- Separate OAuth fixtures for opaque vs JWT tokens
|
||||||
|
- **caldav**: Fix caldav search() due to missing todos
|
||||||
|
- **caldav**: Check that calendar exists after creation to avoid race condition
|
||||||
|
- **caldav**: Properly parse datetimes as vDDDTypes
|
||||||
|
- Increase HTTP client timeout to 30s
|
||||||
|
- Handle RequestError in mcp tools
|
||||||
|
- **deps**: update dependency mcp to >=1.18,<1.19
|
||||||
|
- **deps**: update dependency pillow to v12
|
||||||
|
- **oauth**: Remove the option to force_register new clients
|
||||||
|
- Update user/groups API to OCS v2
|
||||||
|
- **deps**: update dependency mcp to >=1.17,<1.18
|
||||||
|
- **deps**: update dependency mcp to >=1.16,<1.17
|
||||||
|
- **deps**: update dependency mcp to >=1.15,<1.16
|
||||||
|
- **docker**: Provide --host 0.0.0.0 in default docker image
|
||||||
|
- **deps**: update dependency mcp to >=1.13,<1.14
|
||||||
|
- **server**: Replace ErrorResponses with standard McpErrors
|
||||||
|
- **notes**: Include ETags in responses to avoid accidently updates
|
||||||
|
- **notes**: Remove note contents from responses to reduce token usage
|
||||||
|
- **model**: Serialize timestamps in RFC3339 format
|
||||||
|
- **client**: Use paging to fetch all notes
|
||||||
|
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||||
|
- **calendar**: Fix iCalendar date vs datetime format
|
||||||
|
- **calendar**: Remove try/except in calendar API
|
||||||
|
- apply ruff formatting to pass CI checks
|
||||||
|
- **calendar**: address PR feedback from maintainer
|
||||||
|
- apply ruff formatting to test_webdav_operations.py
|
||||||
|
- **deps**: update dependency mcp to >=1.10,<1.11
|
||||||
|
- update tests
|
||||||
|
- Commitizen release process
|
||||||
|
- Do not update dependencies when running in Dockerfile
|
||||||
|
- Configure logging
|
||||||
|
- Limit search results to notes with score > 0.5
|
||||||
|
- Install deps before checking service
|
||||||
|
- **deps**: update dependency mcp to >=1.9,<1.10
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- Transform document parsing into pluggable processor architecture
|
||||||
|
- Update JWT client to use DCR, re-enable tool filtering
|
||||||
|
- Migrate from internal CalendarClient to caldav library
|
||||||
|
- Unify logging & remove factory deployment
|
||||||
|
- Add tools for all resources to enable tool-only workflows
|
||||||
|
- Add `http` to --transport option
|
||||||
|
- Use _make_request where available
|
||||||
|
- **calendar**: optimize logging for production readiness
|
||||||
|
- Modularize NC and Notes app client
|
||||||
|
|
||||||
|
### Perf
|
||||||
|
|
||||||
|
- **notes**: Improve notes search performance using async iterators
|
||||||
@@ -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.0
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.35.0
|
version: 1.36.0
|
||||||
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
|
digest: sha256:7f0979ec4110ff41ebeb55bf586b41366a350cc39fe65a2da7d2da03f723fe9b
|
||||||
generated: "2025-11-21T11:09:07.997781541Z"
|
generated: "2025-12-22T11:09:39.166328543Z"
|
||||||
|
|||||||
@@ -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.48.2
|
version: 0.54.0
|
||||||
appVersion: "0.48.2"
|
appVersion: "0.57.0"
|
||||||
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.0"
|
version: "1.16.3"
|
||||||
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.35.0"
|
version: "1.36.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ Create the name of the secret to use for basic auth
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the secret to use for multi-user basic auth
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.multiUserBasicSecretName" -}}
|
||||||
|
{{- if .Values.auth.multiUserBasic.existingSecret }}
|
||||||
|
{{- .Values.auth.multiUserBasic.existingSecret }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the PVC to use for multi-user basic token storage
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.multiUserBasicPvcName" -}}
|
||||||
|
{{- if .Values.auth.multiUserBasic.persistence.existingClaim }}
|
||||||
|
{{- .Values.auth.multiUserBasic.persistence.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Create the name of the secret to use for OAuth
|
Create the name of the secret to use for OAuth
|
||||||
*/}}
|
*/}}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ spec:
|
|||||||
- name: NEXTCLOUD_HOST
|
- name: NEXTCLOUD_HOST
|
||||||
value: {{ .Values.nextcloud.host | quote }}
|
value: {{ .Values.nextcloud.host | quote }}
|
||||||
{{- if eq .Values.auth.mode "basic" }}
|
{{- if eq .Values.auth.mode "basic" }}
|
||||||
# Basic auth mode
|
# Basic auth mode (single-user)
|
||||||
- name: NEXTCLOUD_USERNAME
|
- name: NEXTCLOUD_USERNAME
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -79,6 +79,41 @@ spec:
|
|||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
|
||||||
key: {{ .Values.auth.basic.passwordKey }}
|
key: {{ .Values.auth.basic.passwordKey }}
|
||||||
|
{{- else if eq .Values.auth.mode "multi-user-basic" }}
|
||||||
|
# Multi-user BasicAuth mode (pass-through)
|
||||||
|
- name: ENABLE_MULTI_USER_BASIC_AUTH
|
||||||
|
value: "true"
|
||||||
|
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
|
||||||
|
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
|
||||||
|
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
|
||||||
|
# Background operations with app passwords
|
||||||
|
- name: ENABLE_OFFLINE_ACCESS
|
||||||
|
value: "true"
|
||||||
|
- name: TOKEN_STORAGE_DB
|
||||||
|
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
|
||||||
|
- name: TOKEN_ENCRYPTION_KEY
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
|
||||||
|
- name: NEXTCLOUD_OIDC_SCOPES
|
||||||
|
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.clientId }}
|
||||||
|
# Static OAuth credentials (optional - uses DCR if not provided)
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_ID
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.clientIdKey }}
|
||||||
|
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
|
||||||
|
key: {{ .Values.auth.multiUserBasic.clientSecretKey }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
{{- else if eq .Values.auth.mode "oauth" }}
|
{{- else if eq .Values.auth.mode "oauth" }}
|
||||||
# OAuth mode
|
# OAuth mode
|
||||||
- name: NEXTCLOUD_MCP_SERVER_URL
|
- name: NEXTCLOUD_MCP_SERVER_URL
|
||||||
@@ -251,6 +286,10 @@ 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: token-storage
|
||||||
|
mountPath: /app/data
|
||||||
|
{{- end }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||||
- name: qdrant-data
|
- name: qdrant-data
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
@@ -266,6 +305,11 @@ 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: token-storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||||
|
{{- end }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||||
- name: qdrant-data
|
- name: qdrant-data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
@@ -16,6 +16,24 @@ 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) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
{{- end }}
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
|
|||||||
@@ -13,6 +13,24 @@ data:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
|
{{- if eq .Values.auth.mode "multi-user-basic" }}
|
||||||
|
{{- if and .Values.auth.multiUserBasic.enableOfflineAccess (not .Values.auth.multiUserBasic.existingSecret) }}
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
|
||||||
|
labels:
|
||||||
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
|
type: Opaque
|
||||||
|
data:
|
||||||
|
{{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}: {{ .Values.auth.multiUserBasic.tokenEncryptionKey | b64enc | quote }}
|
||||||
|
{{- if .Values.auth.multiUserBasic.clientId }}
|
||||||
|
{{ .Values.auth.multiUserBasic.clientIdKey }}: {{ .Values.auth.multiUserBasic.clientId | b64enc | quote }}
|
||||||
|
{{ .Values.auth.multiUserBasic.clientSecretKey }}: {{ .Values.auth.multiUserBasic.clientSecret | b64enc | quote }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
---
|
||||||
{{- if eq .Values.auth.mode "oauth" }}
|
{{- if eq .Values.auth.mode "oauth" }}
|
||||||
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
|
|||||||
@@ -33,14 +33,15 @@ nextcloud:
|
|||||||
publicIssuerUrl: ""
|
publicIssuerUrl: ""
|
||||||
|
|
||||||
# Authentication configuration
|
# Authentication configuration
|
||||||
# Choose either basic auth OR oauth (not both)
|
# Choose one mode: "basic", "multi-user-basic", or "oauth"
|
||||||
auth:
|
auth:
|
||||||
# Authentication mode: "basic" or "oauth"
|
# Authentication mode: "basic", "multi-user-basic", or "oauth"
|
||||||
# basic: Uses username/password (recommended for most users)
|
# basic: Single-user with username/password (recommended for personal use)
|
||||||
|
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
|
||||||
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
|
||||||
mode: basic
|
mode: basic
|
||||||
|
|
||||||
# Basic authentication settings
|
# Basic authentication settings (single-user mode)
|
||||||
basic:
|
basic:
|
||||||
# Nextcloud username (ignored if existingSecret is set)
|
# Nextcloud username (ignored if existingSecret is set)
|
||||||
username: ""
|
username: ""
|
||||||
@@ -58,6 +59,47 @@ auth:
|
|||||||
usernameKey: "username"
|
usernameKey: "username"
|
||||||
passwordKey: "password"
|
passwordKey: "password"
|
||||||
|
|
||||||
|
# Multi-user BasicAuth settings (pass-through mode)
|
||||||
|
# Users provide credentials in request headers (Authorization: Basic ...)
|
||||||
|
# Server optionally stores app passwords for background operations
|
||||||
|
multiUserBasic:
|
||||||
|
# Enable offline access (background operations using app passwords via Astrolabe)
|
||||||
|
# When enabled, requires token encryption key. OAuth client credentials are optional (uses DCR if not provided)
|
||||||
|
enableOfflineAccess: false
|
||||||
|
# Token encryption key (required if enableOfflineAccess: true, ignored if existingSecret is set)
|
||||||
|
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
tokenEncryptionKey: ""
|
||||||
|
# Token storage database path
|
||||||
|
tokenStorageDb: "/app/data/tokens.db"
|
||||||
|
# OAuth client credentials (optional - uses Dynamic Client Registration if not provided)
|
||||||
|
# Only needed if enableOfflineAccess: true
|
||||||
|
clientId: ""
|
||||||
|
clientSecret: ""
|
||||||
|
# OAuth scopes to request (space-separated)
|
||||||
|
scopes: "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
|
||||||
|
# Use existing secret for multi-user basic auth credentials
|
||||||
|
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
|
||||||
|
# Secret should contain keys specified in the *Key fields below
|
||||||
|
# Example:
|
||||||
|
# kubectl create secret generic my-multiuser-creds \
|
||||||
|
# --from-literal=token_encryption_key=ESF1BvEQ... \
|
||||||
|
# --from-literal=client_id=my-client-id \
|
||||||
|
# --from-literal=client_secret=my-client-secret
|
||||||
|
existingSecret: ""
|
||||||
|
# Keys in the existing secret
|
||||||
|
tokenEncryptionKeyKey: "token_encryption_key"
|
||||||
|
clientIdKey: "client_id"
|
||||||
|
clientSecretKey: "client_secret"
|
||||||
|
# Persistent storage for token database
|
||||||
|
persistence:
|
||||||
|
enabled: true
|
||||||
|
# Storage class (leave empty for default)
|
||||||
|
storageClass: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
size: 100Mi
|
||||||
|
# Use existing PVC
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
# OAuth2/OIDC settings (experimental)
|
# OAuth2/OIDC settings (experimental)
|
||||||
oauth:
|
oauth:
|
||||||
# OAuth token type: "jwt" or "opaque"
|
# OAuth token type: "jwt" or "opaque"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# CI-specific overrides for RAG evaluation pipeline
|
||||||
|
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||||
|
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||||
|
#
|
||||||
|
# Environment variables (set in CI workflow):
|
||||||
|
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||||
|
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||||
|
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||||
|
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp:
|
||||||
|
environment:
|
||||||
|
# OpenAI provider configuration (required for CI vector sync)
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||||
|
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||||
|
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||||
|
# Faster sync for CI
|
||||||
|
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||||
|
# Enable document processing for PDF parsing
|
||||||
|
- ENABLE_DOCUMENT_PROCESSING=true
|
||||||
+56
-6
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
|
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
@@ -34,7 +34,8 @@ services:
|
|||||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||||
# 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
|
||||||
@@ -51,7 +52,7 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||||
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
|
||||||
@@ -86,7 +87,7 @@ services:
|
|||||||
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
|
||||||
# Vector sync configuration (ADR-007)
|
# Vector sync configuration (ADR-007)
|
||||||
- VECTOR_SYNC_ENABLED=true
|
#- VECTOR_SYNC_ENABLED=true
|
||||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
@@ -122,6 +123,41 @@ services:
|
|||||||
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||||
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
||||||
|
|
||||||
|
mcp-multi-user-basic:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
command: ["--transport", "streamable-http"]
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8003:8000
|
||||||
|
environment:
|
||||||
|
# Multi-user BasicAuth pass-through mode (ADR-020)
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
|
# Token storage (required for middleware initialization)
|
||||||
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
- VECTOR_SYNC_ENABLED=true
|
||||||
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
|
# OAuth credentials for background sync (optional - uses DCR if not provided)
|
||||||
|
# Uncomment to avoid DCR:
|
||||||
|
# - NEXTCLOUD_OIDC_CLIENT_ID=your_client_id
|
||||||
|
# - NEXTCLOUD_OIDC_CLIENT_SECRET=your_client_secret
|
||||||
|
|
||||||
|
# NO admin credentials - credentials come from client Authorization header
|
||||||
|
volumes:
|
||||||
|
- multi-user-basic-data:/app/data
|
||||||
|
|
||||||
mcp-oauth:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
@@ -150,6 +186,19 @@ 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)
|
||||||
|
- VECTOR_SYNC_ENABLED=true
|
||||||
|
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||||
|
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||||
|
|
||||||
|
# Qdrant configuration - persistent local storage
|
||||||
|
- QDRANT_LOCATION=/app/data/qdrant
|
||||||
|
|
||||||
|
# Embedding provider for vector sync (use Simple provider as fallback)
|
||||||
|
# Ollama not available in CI/test environments
|
||||||
|
# - OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||||
# Client credentials registered via RFC 7591 and stored in volume
|
# Client credentials registered via RFC 7591 and stored in volume
|
||||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||||
@@ -158,7 +207,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -245,7 +294,7 @@ services:
|
|||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
@@ -271,3 +320,4 @@ volumes:
|
|||||||
keycloak-oauth-storage:
|
keycloak-oauth-storage:
|
||||||
qdrant-data:
|
qdrant-data:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
|
multi-user-basic-data:
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
|
||||||
|
|
||||||
|
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
|
||||||
|
- No behavioral hints for clients about read-only, destructive, or idempotent operations
|
||||||
|
- Missing parameter descriptions for better auto-completion and inline help
|
||||||
|
- Clients cannot optimize caching, warn before destructive operations, or retry safely
|
||||||
|
|
||||||
|
### Available MCP Annotations
|
||||||
|
|
||||||
|
The MCP SDK provides three types of annotations:
|
||||||
|
|
||||||
|
#### 1. Tool Decorator Parameters
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Human-Readable Name",
|
||||||
|
description="Tool description", # Can also come from docstring
|
||||||
|
annotations=ToolAnnotations(...),
|
||||||
|
icons=[Icon(...)] # Optional visual icons
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. ToolAnnotations Behavioral Hints
|
||||||
|
```python
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
ToolAnnotations(
|
||||||
|
title="Alternative Title", # Decorator title takes precedence
|
||||||
|
readOnlyHint=True, # Tool doesn't modify data
|
||||||
|
destructiveHint=True, # Tool may delete/overwrite data
|
||||||
|
idempotentHint=True, # Repeated calls with same args are safe
|
||||||
|
openWorldHint=True # Interacts with external entities
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Parameter Descriptions
|
||||||
|
```python
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
async def tool(
|
||||||
|
param: str = Field(description="What this parameter does"),
|
||||||
|
ctx: Context
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
### Idempotency Analysis
|
||||||
|
|
||||||
|
**Important**: Idempotency means calling with **the same inputs** produces the same result.
|
||||||
|
|
||||||
|
**NOT Idempotent** (different inputs each call):
|
||||||
|
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
|
||||||
|
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
|
||||||
|
- Different input (stale etag) → different result (error)
|
||||||
|
- **Creates**: `create_note(title="X")` → creates note 1
|
||||||
|
- Second call → creates note 2 (different result)
|
||||||
|
- **Append operations**: `append_content(id=1, text="X")` → adds X once
|
||||||
|
- Second call → adds X again (different result)
|
||||||
|
|
||||||
|
**Idempotent**:
|
||||||
|
- **Deletes**: `delete_note(id=1)` → note deleted
|
||||||
|
- Second call → 404 or success (same end state: note doesn't exist)
|
||||||
|
- Note: May return different status code, but end state is identical
|
||||||
|
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
|
||||||
|
- Second call → file still has "Hello" (same end state)
|
||||||
|
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
|
||||||
|
- **Set operations**: `set_property(id=1, value="X")` → property = X
|
||||||
|
- Second call → property still = X (same result)
|
||||||
|
- Note: Nextcloud updates with etags use version control, so not idempotent
|
||||||
|
|
||||||
|
**Read-Only** (always idempotent, never destructive):
|
||||||
|
- All list, search, get operations
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add annotations to all 101 tools in three phases:
|
||||||
|
|
||||||
|
### Phase 1: Titles (Quick Win)
|
||||||
|
Add human-readable titles to all tools:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(title="Create Note")
|
||||||
|
async def nc_notes_create_note(...):
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 2-3 hours
|
||||||
|
**Impact**: Immediate UX improvement
|
||||||
|
|
||||||
|
### Phase 2: ToolAnnotations (Behavioral Hints)
|
||||||
|
Add annotations based on corrected categorization:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Read-only tools
|
||||||
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True # Nextcloud is external to MCP server
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete tools (idempotent: same end state)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=True, # Deleting deleted item = same end state
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tools (not idempotent: creates multiple items)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Create Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update tools with etag (not idempotent: etag changes)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Etag required = different inputs each time
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append operations (not idempotent: adds content each time)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Append to Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 4-6 hours
|
||||||
|
**Impact**: Better client behavior (caching, warnings, retry logic)
|
||||||
|
|
||||||
|
### Phase 3: Parameter Descriptions
|
||||||
|
Add Field() descriptions to parameters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
|
||||||
|
async def nc_notes_create_note(
|
||||||
|
title: str = Field(description="The title of the note"),
|
||||||
|
content: str = Field(description="Markdown content of the note"),
|
||||||
|
category: str = Field(description="Category or folder name for organizing"),
|
||||||
|
ctx: Context
|
||||||
|
) -> CreateNoteResponse:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 6-8 hours
|
||||||
|
**Impact**: Better auto-completion and inline help
|
||||||
|
|
||||||
|
## Tool Categorization
|
||||||
|
|
||||||
|
### Read-Only Tools (~40 tools)
|
||||||
|
**Pattern**: List, search, get operations
|
||||||
|
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_search_notes` → "Search Notes"
|
||||||
|
- `nc_webdav_list_directory` → "List Files and Directories"
|
||||||
|
- `nc_calendar_list_calendars` → "List Calendars"
|
||||||
|
- `nc_contacts_get_contact` → "Get Contact"
|
||||||
|
- `nc_semantic_search` → "Semantic Search"
|
||||||
|
- `check_logged_in` → "Check Server Login Status"
|
||||||
|
|
||||||
|
### Create Tools (~20 tools)
|
||||||
|
**Pattern**: Create new resources
|
||||||
|
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_create_note` → "Create Note"
|
||||||
|
- `nc_calendar_create_event` → "Create Calendar Event"
|
||||||
|
- `nc_contacts_create_contact` → "Create Contact"
|
||||||
|
- `deck_create_card` → "Create Kanban Card"
|
||||||
|
- `nc_tables_create_row` → "Create Table Row"
|
||||||
|
|
||||||
|
### Update Tools (~25 tools)
|
||||||
|
**Pattern**: Modify existing resources with etag
|
||||||
|
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_update_note` → "Update Note"
|
||||||
|
- `nc_calendar_update_event` → "Update Calendar Event"
|
||||||
|
- `nc_contacts_update_contact` → "Update Contact"
|
||||||
|
- `deck_update_card` → "Update Kanban Card"
|
||||||
|
|
||||||
|
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
|
||||||
|
|
||||||
|
### Append/Accumulate Tools (~5 tools)
|
||||||
|
**Pattern**: Add content without replacing
|
||||||
|
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_append_content` → "Append to Note"
|
||||||
|
|
||||||
|
**Rationale**: Each call adds content, changing the result = NOT idempotent.
|
||||||
|
|
||||||
|
### Delete Tools (~10 tools)
|
||||||
|
**Pattern**: Remove resources
|
||||||
|
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_delete_note` → "Delete Note"
|
||||||
|
- `nc_webdav_delete_resource` → "Delete File or Directory"
|
||||||
|
- `nc_calendar_delete_event` → "Delete Calendar Event"
|
||||||
|
- `nc_contacts_delete_contact` → "Delete Contact"
|
||||||
|
|
||||||
|
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
|
||||||
|
|
||||||
|
### Special Cases
|
||||||
|
|
||||||
|
#### OAuth Provisioning Tools
|
||||||
|
```python
|
||||||
|
# Not read-only but requires user interaction
|
||||||
|
@mcp.tool(
|
||||||
|
title="Grant Server Access to Nextcloud",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
idempotentHint=False, # Creates new OAuth session each time
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def provision_nextcloud_access(ctx: Context):
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Semantic Search (Closed World)
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Semantic Search",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=False # Searches only indexed Nextcloud data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def nc_semantic_search(query: str, ctx: Context):
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
|
||||||
|
|
||||||
|
## Tool Priority Matrix
|
||||||
|
|
||||||
|
### Critical Priority (~2 tools)
|
||||||
|
OAuth tools required for server functionality:
|
||||||
|
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
|
||||||
|
- `check_logged_in` → "Check Server Login Status"
|
||||||
|
|
||||||
|
### High Priority (~50 tools)
|
||||||
|
Most commonly used modules:
|
||||||
|
- **Notes** (14 tools): Create, read, update, delete notes
|
||||||
|
- **WebDAV** (13 tools): File operations
|
||||||
|
- **Calendar** (15 tools): Events and todos
|
||||||
|
- **Semantic Search** (6 tools): AI-powered search
|
||||||
|
- **Contacts** (9 tools): Address book operations
|
||||||
|
|
||||||
|
### Medium Priority (~35 tools)
|
||||||
|
Secondary functionality:
|
||||||
|
- **Deck** (9 tools): Kanban boards
|
||||||
|
- **Tables** (7 tools): Structured data
|
||||||
|
- **Sharing** (5 tools): File sharing
|
||||||
|
|
||||||
|
### Low Priority (~14 tools)
|
||||||
|
Less frequently used:
|
||||||
|
- **Cookbook** (8 tools): Recipe management
|
||||||
|
- **News** (6 tools): RSS feeds
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Week 1: Phase 1 - Titles
|
||||||
|
- Add human-readable titles to all 101 tools
|
||||||
|
- Update tool name mapping in documentation
|
||||||
|
- Manual test in MCP inspector
|
||||||
|
|
||||||
|
### Week 2: Phase 2 - ToolAnnotations (High Priority)
|
||||||
|
- Add annotations to Critical and High priority tools (~52 tools)
|
||||||
|
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
|
||||||
|
- Add unit tests validating annotation presence
|
||||||
|
|
||||||
|
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
|
||||||
|
- Complete remaining tools (~49 tools)
|
||||||
|
- Deck, Tables, Contacts, Cookbook, News
|
||||||
|
- Update tool listings in README
|
||||||
|
|
||||||
|
### Week 4: Phase 3 - Parameter Descriptions
|
||||||
|
- Add Field() descriptions to Critical/High priority tools
|
||||||
|
- Start with OAuth, Notes, WebDAV modules
|
||||||
|
- Incremental completion over time
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
|
||||||
|
- **Safety**: Warnings before destructive operations
|
||||||
|
- **Better help**: Parameter descriptions in auto-completion
|
||||||
|
- **Confidence**: Know which operations are safe to retry
|
||||||
|
|
||||||
|
### For MCP Clients
|
||||||
|
- **Caching**: Cache results from read-only tools
|
||||||
|
- **Safety prompts**: Warn before destructiveHint=true
|
||||||
|
- **Retry logic**: Safely retry idempotent operations
|
||||||
|
- **UI organization**: Group by behavior (reads vs writes vs deletes)
|
||||||
|
- **Performance**: Optimize based on hints
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- **Self-documenting**: Behavior is explicit
|
||||||
|
- **Consistency**: Standard patterns across codebase
|
||||||
|
- **Testing**: Validate annotations match implementation
|
||||||
|
- **Maintenance**: Clear expectations for new tools
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Immediate UX improvement with minimal effort
|
||||||
|
- Clients can make smarter decisions
|
||||||
|
- Self-documenting code
|
||||||
|
- Follows MCP best practices
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Initial effort to add annotations (12-15 hours total)
|
||||||
|
- Must maintain annotations when adding new tools
|
||||||
|
- Risk of incorrect annotations misleading clients
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- Annotations are hints, not guarantees
|
||||||
|
- Clients may ignore annotations
|
||||||
|
- Backward compatible (additive change)
|
||||||
|
|
||||||
|
### Mitigations
|
||||||
|
- **Incorrect annotations**: Add tests validating behavior matches hints
|
||||||
|
- **Maintenance burden**: Add to code review checklist and tool template
|
||||||
|
- **Documentation**: Update CLAUDE.md with annotation guidelines
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Delete)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Deletes data permanently
|
||||||
|
idempotentHint=True, # Same end state (note doesn't exist)
|
||||||
|
openWorldHint=True # Nextcloud is external
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_delete_note(
|
||||||
|
note_id: int = Field(description="The ID of the note to delete permanently"),
|
||||||
|
ctx: Context
|
||||||
|
) -> DeleteNoteResponse:
|
||||||
|
"""Delete a note permanently (requires notes:write scope)"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Update)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # NOT idempotent: etag changes each update
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_update_note(
|
||||||
|
note_id: int = Field(description="The ID of the note to update"),
|
||||||
|
title: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New title (omit to keep current)"
|
||||||
|
),
|
||||||
|
content: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New markdown content (omit to keep current)"
|
||||||
|
),
|
||||||
|
category: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New category/folder (omit to keep current)"
|
||||||
|
),
|
||||||
|
etag: str = Field(
|
||||||
|
description="ETag from get_note (prevents concurrent modification)"
|
||||||
|
),
|
||||||
|
ctx: Context
|
||||||
|
) -> UpdateNoteResponse:
|
||||||
|
"""Update an existing note's title, content, or category.
|
||||||
|
|
||||||
|
The etag parameter is required to prevent overwriting concurrent changes.
|
||||||
|
Get the current ETag by first calling nc_notes_get_note.
|
||||||
|
If the note has been modified since you retrieved it, the update will fail.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Read-Only)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Doesn't modify data
|
||||||
|
openWorldHint=True # Queries Nextcloud
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_search_notes(
|
||||||
|
query: str = Field(description="Search term to match in note titles or content"),
|
||||||
|
ctx: Context
|
||||||
|
) -> SearchNotesResponse:
|
||||||
|
"""Search notes by title or content, returning id, title, and category.
|
||||||
|
|
||||||
|
This is a read-only operation that searches across all user notes.
|
||||||
|
Use nc_notes_get_note to retrieve the full content of matching notes.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
Add tests validating annotation presence and correctness:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_notes_tools_have_annotations():
|
||||||
|
"""Verify all notes tools have appropriate annotations."""
|
||||||
|
tools = get_registered_tools(mcp)
|
||||||
|
|
||||||
|
# Check create tool
|
||||||
|
create_tool = tools["nc_notes_create_note"]
|
||||||
|
assert create_tool.title == "Create Note"
|
||||||
|
assert create_tool.annotations.idempotentHint is False
|
||||||
|
|
||||||
|
# Check delete tool
|
||||||
|
delete_tool = tools["nc_notes_delete_note"]
|
||||||
|
assert delete_tool.title == "Delete Note"
|
||||||
|
assert delete_tool.annotations.destructiveHint is True
|
||||||
|
assert delete_tool.annotations.idempotentHint is True
|
||||||
|
|
||||||
|
# Check read-only tool
|
||||||
|
search_tool = tools["nc_notes_search_notes"]
|
||||||
|
assert search_tool.title == "Search Notes"
|
||||||
|
assert search_tool.annotations.readOnlyHint is True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Verify existing tests pass with annotations
|
||||||
|
- Manual testing in MCP inspector/client
|
||||||
|
|
||||||
|
### Documentation Updates
|
||||||
|
- Update README tool listings with new titles
|
||||||
|
- Add annotation guidelines to CLAUDE.md
|
||||||
|
- Include examples in developer documentation
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
|
||||||
|
- **Decision**: Mark as `idempotentHint=True`
|
||||||
|
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
|
||||||
|
|
||||||
|
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
|
||||||
|
- **Decision**: Mark as `openWorldHint=True`
|
||||||
|
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
|
||||||
|
|
||||||
|
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
|
||||||
|
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
|
||||||
|
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- MCP Python SDK: `/home/chris/Software/python-sdk/`
|
||||||
|
- ToolAnnotations spec: `src/mcp/types.py:1247`
|
||||||
|
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
|
||||||
|
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
|
||||||
|
|
||||||
|
## Decision Timeline
|
||||||
|
|
||||||
|
- **Proposed**: 2025-12-11
|
||||||
|
- **Reviewed**: 2025-12-11 (Self-review during implementation)
|
||||||
|
- **Accepted**: 2025-12-11
|
||||||
|
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,342 @@
|
|||||||
|
# ADR-020: Deployment Modes and Configuration Validation
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2025-12-20
|
||||||
|
**Deciders:** Development Team
|
||||||
|
**Related:** ADR-002 (Vector Sync), ADR-004 (Progressive Consent), ADR-019 (Multi-user BasicAuth)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MCP server supports multiple deployment scenarios with different authentication methods, storage backends, and feature sets. Over time, the configuration system evolved to support ~500+ possible combinations across deployment modes, authentication patterns, and feature toggles. This complexity made it difficult to:
|
||||||
|
|
||||||
|
1. Understand what configuration is required for a given deployment
|
||||||
|
2. Debug configuration errors (validation scattered across multiple files)
|
||||||
|
3. Provide helpful error messages when configuration is invalid
|
||||||
|
4. Maintain clear boundaries between deployment modes
|
||||||
|
|
||||||
|
**Problems Identified:**
|
||||||
|
- No single source of truth for "what config is required for mode X"
|
||||||
|
- Validation happening at 4+ different points (Settings.__post_init__, setup_oauth_config(), context helpers, starlette_lifespan)
|
||||||
|
- Startup sequence unclear (OAuth setup before FastMCP creation, sync initialization errors)
|
||||||
|
- Error messages generic ("X is required") without explaining which deployment mode triggered the requirement
|
||||||
|
- Multiple overlapping decision trees (deployment mode, auth mode, features)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We formalize five distinct deployment modes with explicit configuration requirements and implement centralized configuration validation.
|
||||||
|
|
||||||
|
### Deployment Modes
|
||||||
|
|
||||||
|
#### 1. Single-User BasicAuth
|
||||||
|
|
||||||
|
**Use Case:** Personal Nextcloud instance, local development
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password # Or app password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# Vector sync (semantic search)
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
QDRANT_LOCATION=/path/to/qdrant # Or QDRANT_URL for remote
|
||||||
|
|
||||||
|
# Embeddings (optional - Simple provider used as fallback)
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Document processing
|
||||||
|
DOCUMENT_CHUNK_SIZE=512
|
||||||
|
DOCUMENT_CHUNK_OVERLAP=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Single shared NextcloudClient created at startup
|
||||||
|
- No OAuth infrastructure needed
|
||||||
|
- No multi-user support
|
||||||
|
- Vector sync runs as single-user background task
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. Multi-User BasicAuth Pass-Through
|
||||||
|
|
||||||
|
**Use Case:** Internal deployment where users provide their own credentials, no background sync needed
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# For background sync (requires app passwords from Astrolabe)
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# ... plus Qdrant and embedding config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conditional Requirements:**
|
||||||
|
- If `ENABLE_OFFLINE_ACCESS=true`: requires `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
|
||||||
|
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- No OAuth for client authentication (uses BasicAuth in request headers)
|
||||||
|
- BasicAuthMiddleware extracts credentials from Authorization header
|
||||||
|
- Client created per-request from extracted credentials
|
||||||
|
- Optional: Background sync using app passwords (via Astrolabe API)
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. OAuth Single-Audience (Default)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment with OAuth authentication, tokens work for both MCP and Nextcloud
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-Configured:**
|
||||||
|
- OIDC discovery URL: `{NEXTCLOUD_HOST}/.well-known/openid-configuration`
|
||||||
|
- Client credentials: Dynamic Client Registration (DCR) if available
|
||||||
|
- Token storage: SQLite at `~/.oauth/clients.db`
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# Static client credentials (instead of DCR)
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
|
||||||
|
# Offline access for background sync
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# ... plus Qdrant and embedding config
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conditional Requirements:**
|
||||||
|
- If `ENABLE_OFFLINE_ACCESS=true`: requires `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
|
||||||
|
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Tokens contain both `aud: ["mcp-server", "nextcloud"]`
|
||||||
|
- Pass token through to Nextcloud APIs (no exchange)
|
||||||
|
- Client created per-request from token in Authorization header
|
||||||
|
- Background sync uses refresh tokens (if offline_access enabled)
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. OAuth Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment where MCP token is separate from Nextcloud token
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
- Same as OAuth Single-Audience, plus:
|
||||||
|
```bash
|
||||||
|
TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Tokens contain only `aud: "mcp-server"`
|
||||||
|
- MCP server exchanges token for Nextcloud token via RFC 8693
|
||||||
|
- Exchanged tokens cached per-user
|
||||||
|
- Client created per-request using exchanged token
|
||||||
|
- Background sync uses refresh tokens (if offline_access enabled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. Smithery Stateless
|
||||||
|
|
||||||
|
**Use Case:** Multi-tenant SaaS deployment via Smithery platform
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
- None! Configuration comes from session URL params: `?nextcloud_url=...&username=...&app_password=...`
|
||||||
|
|
||||||
|
**Forbidden Configuration:**
|
||||||
|
- Must NOT set: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`, `ENABLE_MULTI_USER_BASIC_AUTH`, `ENABLE_TOKEN_EXCHANGE`, `ENABLE_OFFLINE_ACCESS`, `VECTOR_SYNC_ENABLED`, `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- No persistent storage (stateless)
|
||||||
|
- Client created per-request from session config
|
||||||
|
- No vector sync (disabled)
|
||||||
|
- No admin UI (no /app routes)
|
||||||
|
- No OAuth infrastructure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration Validation
|
||||||
|
|
||||||
|
**Implementation:** `nextcloud_mcp_server/config_validators.py`
|
||||||
|
|
||||||
|
**Key Functions:**
|
||||||
|
```python
|
||||||
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||||
|
"""Detect authentication mode from configuration.
|
||||||
|
|
||||||
|
Priority (most specific to most general):
|
||||||
|
1. Smithery (explicit flag)
|
||||||
|
2. Token exchange (most specific OAuth mode)
|
||||||
|
3. Multi-user BasicAuth
|
||||||
|
4. Single-user BasicAuth
|
||||||
|
5. OAuth single-audience (default OAuth mode)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
||||||
|
"""Validate configuration for detected mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (detected_mode, list_of_errors)
|
||||||
|
Empty list means valid configuration.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- **Required variables:** Must be set and non-empty
|
||||||
|
- **Forbidden variables:** Must NOT be set (or must be False for booleans)
|
||||||
|
- **Conditional requirements:** If feature X is enabled, requires variables Y and Z
|
||||||
|
|
||||||
|
**Error Messages:**
|
||||||
|
```
|
||||||
|
Configuration validation failed for {mode} mode:
|
||||||
|
- [{mode}] Missing required configuration: NEXTCLOUD_HOST
|
||||||
|
- [{mode}] ENABLE_OFFLINE_ACCESS must be enabled when VECTOR_SYNC_ENABLED is true
|
||||||
|
|
||||||
|
Mode: {mode}
|
||||||
|
Description: {mode_description}
|
||||||
|
|
||||||
|
Required configuration:
|
||||||
|
- VAR1
|
||||||
|
- VAR2
|
||||||
|
|
||||||
|
Optional configuration:
|
||||||
|
- VAR3
|
||||||
|
- VAR4
|
||||||
|
|
||||||
|
Conditional requirements:
|
||||||
|
When FEATURE is enabled:
|
||||||
|
- VAR5
|
||||||
|
- VAR6
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- Validation runs at app startup in `get_app()` (app.py:1048-1062)
|
||||||
|
- All errors reported before any initialization begins
|
||||||
|
- Mode-specific error messages explain requirements
|
||||||
|
- Validation uses the same Settings object used throughout the app
|
||||||
|
|
||||||
|
### Configuration Matrix
|
||||||
|
|
||||||
|
| Variable | Single BasicAuth | Multi BasicAuth | OAuth Single | OAuth Exchange | Smithery |
|
||||||
|
|----------|------------------|-----------------|--------------|----------------|----------|
|
||||||
|
| **NEXTCLOUD_HOST** | Required | Required | Required | Required | Forbidden |
|
||||||
|
| **NEXTCLOUD_USERNAME** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **NEXTCLOUD_PASSWORD** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **ENABLE_MULTI_USER_BASIC_AUTH** | Forbidden | Required | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **ENABLE_TOKEN_EXCHANGE** | Forbidden | Forbidden | Forbidden | Required | Forbidden |
|
||||||
|
| **ENABLE_OFFLINE_ACCESS** | Optional\* | Optional\* | Optional\* | Optional\* | Forbidden |
|
||||||
|
| **TOKEN_ENCRYPTION_KEY** | If offline | If offline | If offline | If offline | Forbidden |
|
||||||
|
| **TOKEN_STORAGE_DB** | If offline | If offline | If offline | If offline | Forbidden |
|
||||||
|
| **OIDC_CLIENT_ID** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
|
||||||
|
| **OIDC_CLIENT_SECRET** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
|
||||||
|
| **VECTOR_SYNC_ENABLED** | Optional | Optional | Optional | Optional | Forbidden |
|
||||||
|
| **QDRANT_URL/LOCATION** | If vector | If vector | If vector | If vector | Forbidden |
|
||||||
|
| **OLLAMA_BASE_URL/OPENAI_API_KEY** | Optional | Optional | Optional | Optional | Forbidden |
|
||||||
|
|
||||||
|
\* Only enables background sync for semantic search
|
||||||
|
\*\* Uses DCR if not provided
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Clarity:** Single function to detect mode from config
|
||||||
|
2. **Validation:** All config validated upfront with helpful errors
|
||||||
|
3. **Debugging:** Clear logs showing "Running in X mode with config Y"
|
||||||
|
4. **Maintenance:** Mode-specific logic can be isolated
|
||||||
|
5. **Documentation:** Clear mapping of mode → required config
|
||||||
|
6. **Error Messages:** Context-aware ("X is required for Y mode")
|
||||||
|
7. **Testing:** Each mode testable in isolation
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **Migration:** Existing invalid configurations will now fail at startup
|
||||||
|
2. **Flexibility:** Less flexibility in configuration combinations
|
||||||
|
3. **Strictness:** Some previously-working combinations may be rejected
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. **Backward Compatibility:** Valid configurations continue to work
|
||||||
|
2. **Mode Detection:** Automatic based on config (no explicit mode selection)
|
||||||
|
3. **Default Mode:** OAuth single-audience when no credentials provided
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Embedding Provider Validation
|
||||||
|
|
||||||
|
Originally, validation required either `OLLAMA_BASE_URL` or `OPENAI_API_KEY` when vector sync was enabled. This was too strict because the Simple provider is always available as a fallback (ADR-015). The validation was removed to allow vector sync without explicit provider configuration.
|
||||||
|
|
||||||
|
### Variable Scoping Issues
|
||||||
|
|
||||||
|
During implementation, several Python variable scoping issues were discovered in `app.py`:
|
||||||
|
- Local variable assignments in `starlette_lifespan()` shadowed outer scope variables
|
||||||
|
- Fixed by using unique variable names (e.g., `nextcloud_host_for_context`, `basic_auth_storage`)
|
||||||
|
- Removed redundant `settings = get_settings()` call (re-used outer scope)
|
||||||
|
|
||||||
|
### Docker Compose Configuration
|
||||||
|
|
||||||
|
The `mcp-oauth` service configuration was updated to remove `ENABLE_MULTI_USER_BASIC_AUTH=true` which conflicted with its intended OAuth mode. The service now runs in OAuth single-audience mode with vector sync using the Simple embedding provider as fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
`tests/unit/test_config_validators.py` provides comprehensive coverage:
|
||||||
|
- Mode detection with priority ordering (7 tests)
|
||||||
|
- Single-user BasicAuth validation (8 tests)
|
||||||
|
- Multi-user BasicAuth validation (7 tests)
|
||||||
|
- OAuth single-audience validation (6 tests)
|
||||||
|
- OAuth token exchange validation (3 tests)
|
||||||
|
- Smithery validation (4 tests)
|
||||||
|
- Mode summary generation (3 tests)
|
||||||
|
- Edge cases (3 tests)
|
||||||
|
|
||||||
|
**Total: 41 tests, all passing**
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Integration tests verify that:
|
||||||
|
- Each mode starts successfully with valid configuration
|
||||||
|
- Invalid configurations fail with clear error messages
|
||||||
|
- Existing deployments continue to work
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
|
||||||
|
- [ADR-004: Progressive Consent](ADR-004-progressive-consent.md)
|
||||||
|
- [ADR-015: Unified Provider Architecture](ADR-015-unified-provider-architecture.md)
|
||||||
|
- [ADR-019: Multi-user BasicAuth Pass-Through](ADR-019-multi-user-basicauth-passthrough.md)
|
||||||
|
- Implementation: `nextcloud_mcp_server/config_validators.py`
|
||||||
|
- Tests: `tests/unit/test_config_validators.py`
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# ADR-021: Configuration Consolidation and Simplification
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2025-12-21
|
||||||
|
**Deciders:** Development Team
|
||||||
|
**Related:** ADR-020 (Deployment Modes), ADR-002 (Vector Sync), ADR-004 (Progressive Consent)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The configuration system has grown complex with overlapping concerns that make it difficult for users to switch between deployment modes and understand configuration dependencies.
|
||||||
|
|
||||||
|
### Problems Identified
|
||||||
|
|
||||||
|
1. **Confusing variable names don't reflect purpose**:
|
||||||
|
- `ENABLE_OFFLINE_ACCESS` - Actually controls refresh token storage for background operations, not general "offline" capabilities
|
||||||
|
- `VECTOR_SYNC_ENABLED` - Controls semantic search background indexing (implementation detail, not user-facing feature name)
|
||||||
|
- Users struggle to understand what these variables actually control
|
||||||
|
|
||||||
|
2. **Redundant configuration requirements**:
|
||||||
|
- Multi-user semantic search requires setting BOTH `ENABLE_OFFLINE_ACCESS=true` AND `VECTOR_SYNC_ENABLED=true`
|
||||||
|
- The dependency is one-way (semantic search needs background ops, but background ops don't need semantic search)
|
||||||
|
- Users must understand internal implementation details to configure a user-facing feature
|
||||||
|
|
||||||
|
3. **Implicit mode detection creates ambiguity**:
|
||||||
|
- Five deployment modes detected via priority-based logic
|
||||||
|
- Users can't easily predict which mode will activate
|
||||||
|
- Configuration errors don't clearly indicate which mode triggered the requirement
|
||||||
|
|
||||||
|
4. **OIDC_CLIENT_ID vs NEXTCLOUD_OIDC_CLIENT_ID confusion**:
|
||||||
|
- Investigation revealed these are NOT actually overlapping (`OIDC_CLIENT_ID` is test-only)
|
||||||
|
- However, their similar names create confusion
|
||||||
|
|
||||||
|
### Current Configuration Complexity
|
||||||
|
|
||||||
|
**Example: Multi-user OAuth with semantic search**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # Why is this needed?
|
||||||
|
VECTOR_SYNC_ENABLED=true # And this separately?
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
Users must understand:
|
||||||
|
- Semantic search requires background token storage (ENABLE_OFFLINE_ACCESS)
|
||||||
|
- Background token storage requires encryption keys
|
||||||
|
- The relationship between ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED
|
||||||
|
- Which deployment mode these settings will activate
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We consolidate overlapping functionality and add explicit mode selection while maintaining 100% backward compatibility.
|
||||||
|
|
||||||
|
### 1. Automatic Dependency Resolution
|
||||||
|
|
||||||
|
**Make ENABLE_SEMANTIC_SEARCH the primary control** that automatically enables required dependencies:
|
||||||
|
|
||||||
|
**New behavior**:
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def enable_background_operations(self) -> bool:
|
||||||
|
"""Background operations - auto-enabled by semantic search in multi-user modes."""
|
||||||
|
# Check new names first
|
||||||
|
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
|
||||||
|
# Fall back to old name with deprecation warning
|
||||||
|
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
|
||||||
|
# Auto-enable if semantic search needs it
|
||||||
|
auto_enabled = self.enable_semantic_search and self.is_multi_user_mode()
|
||||||
|
|
||||||
|
return explicit or legacy or auto_enabled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enable_semantic_search(self) -> bool:
|
||||||
|
"""Semantic search - renamed from VECTOR_SYNC_ENABLED."""
|
||||||
|
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
||||||
|
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
||||||
|
return new_value or old_value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result**: Users set `ENABLE_SEMANTIC_SEARCH=true` and the system automatically enables background token storage when needed.
|
||||||
|
|
||||||
|
### 2. Explicit Mode Selection (Optional)
|
||||||
|
|
||||||
|
Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Optional: Explicitly declare deployment mode
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# Valid values: single_user_basic, multi_user_basic,
|
||||||
|
# oauth_single_audience, oauth_token_exchange, smithery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Detection logic**:
|
||||||
|
1. If `MCP_DEPLOYMENT_MODE` is set → validate and use it
|
||||||
|
2. Otherwise → use priority-based auto-detection (existing behavior)
|
||||||
|
3. Validate explicit mode doesn't conflict with detected mode
|
||||||
|
|
||||||
|
### 3. Simplified User Experience
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
# Multi-user OAuth with semantic search
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # Confusing
|
||||||
|
VECTOR_SYNC_ENABLED=true # Why both?
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**After**:
|
||||||
|
```bash
|
||||||
|
# Multi-user OAuth with semantic search
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits**:
|
||||||
|
- 2 fewer variables to understand/set
|
||||||
|
- Clear intent ("I want semantic search")
|
||||||
|
- Explicit mode declaration (optional)
|
||||||
|
- All existing configs continue working
|
||||||
|
|
||||||
|
### 4. Variable Naming Strategy
|
||||||
|
|
||||||
|
**Deprecated (but still functional)**:
|
||||||
|
- `ENABLE_OFFLINE_ACCESS` → Renamed to `ENABLE_BACKGROUND_OPERATIONS`
|
||||||
|
- `VECTOR_SYNC_ENABLED` → Renamed to `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
|
||||||
|
**No change needed**:
|
||||||
|
- `VECTOR_SYNC_SCAN_INTERVAL` - Implementation tuning parameter (keep as-is)
|
||||||
|
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Implementation tuning parameter (keep as-is)
|
||||||
|
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Implementation tuning parameter (keep as-is)
|
||||||
|
|
||||||
|
**Rationale**: Only rename user-facing feature flags, not internal tuning parameters.
|
||||||
|
|
||||||
|
### 5. Backward Compatibility
|
||||||
|
|
||||||
|
**Support both old and new names for minimum 2 major versions**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@property
|
||||||
|
def enable_semantic_search(self) -> bool:
|
||||||
|
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
||||||
|
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
||||||
|
|
||||||
|
if new_value and old_value:
|
||||||
|
logger.warning(
|
||||||
|
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
|
||||||
|
"Using ENABLE_SEMANTIC_SEARCH. VECTOR_SYNC_ENABLED is deprecated."
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_value and not new_value:
|
||||||
|
logger.warning(
|
||||||
|
"VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead."
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_value or old_value
|
||||||
|
```
|
||||||
|
|
||||||
|
**Deprecation timeline**:
|
||||||
|
- v0.6.0: Add new variables, deprecate old ones (both work with warnings)
|
||||||
|
- v1.0.0: Remove old variables (breaking change, well-announced)
|
||||||
|
- Minimum 2 major versions of support (12+ months)
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Reduced cognitive load**: Users set `ENABLE_SEMANTIC_SEARCH=true` instead of understanding internal dependencies
|
||||||
|
2. **Clearer intent**: Variable names reflect user-facing features, not implementation details
|
||||||
|
3. **Explicit mode control**: `MCP_DEPLOYMENT_MODE` removes detection ambiguity
|
||||||
|
4. **Better onboarding**: New users see simpler configuration in env.sample
|
||||||
|
5. **Improved error messages**: Validation can suggest "set MCP_DEPLOYMENT_MODE=X" instead of relying on implicit detection
|
||||||
|
6. **No breaking changes**: All existing configurations continue working
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **Transition period complexity**: Both old and new names supported for 2+ versions
|
||||||
|
2. **Documentation burden**: All docs must be updated to show new approach
|
||||||
|
3. **Test coverage expansion**: Must test both old and new variable names in all modes
|
||||||
|
4. **Migration effort**: Existing deployments should eventually migrate (optional but recommended)
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. **Same functionality**: No new features, just better organization
|
||||||
|
2. **Same validation**: Underlying requirements unchanged (e.g., semantic search still needs Qdrant)
|
||||||
|
3. **Same performance**: No runtime performance impact
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
### Phase 1: Configuration Consolidation (v0.6.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `nextcloud_mcp_server/config.py` - Add property-based deprecation with auto-enablement
|
||||||
|
- `nextcloud_mcp_server/config_validators.py` - Simplify validation (semantic search no longer requires explicit background operations setting)
|
||||||
|
- `nextcloud_mcp_server/app.py` - Add informative logging for auto-enablement
|
||||||
|
- `tests/unit/test_config_validators.py` - Add auto-enablement tests
|
||||||
|
- `docs/configuration-migration-v2.md` - Create migration guide
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. `enable_background_operations` property auto-enables when `enable_semantic_search=true` in multi-user modes
|
||||||
|
2. `enable_semantic_search` property accepts both `ENABLE_SEMANTIC_SEARCH` and `VECTOR_SYNC_ENABLED`
|
||||||
|
3. Smart logging when auto-enablement occurs or deprecated variables used
|
||||||
|
4. Validation simplified to remove redundant requirements
|
||||||
|
|
||||||
|
### Phase 2: Explicit Mode Selection (v0.6.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `nextcloud_mcp_server/config.py` - Add `deployment_mode` field
|
||||||
|
- `nextcloud_mcp_server/config_validators.py` - Check explicit mode first, fall back to auto-detection
|
||||||
|
- `tests/unit/test_config_validators.py` - Test mode override and conflict detection
|
||||||
|
- `docs/configuration.md` - Document mode selection
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Add `MCP_DEPLOYMENT_MODE` environment variable (optional)
|
||||||
|
2. Mode detection checks explicit mode first, then auto-detects
|
||||||
|
3. Validate explicit mode doesn't conflict with detected mode
|
||||||
|
4. Better error messages referencing explicit mode setting
|
||||||
|
|
||||||
|
### Phase 3: env.sample Reorganization (v0.6.0)
|
||||||
|
|
||||||
|
**Files to create/modify**:
|
||||||
|
- `env.sample` - Reorganize by deployment mode
|
||||||
|
- `env.sample.single-user` - Simplest config template
|
||||||
|
- `env.sample.oauth-multi-user` - Multi-user template showing consolidation
|
||||||
|
- `env.sample.oauth-advanced` - Token exchange mode template
|
||||||
|
- `README.md` - Update Quick Start to reference templates
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Group related settings by deployment mode
|
||||||
|
2. Show simplified configuration (only essential variables)
|
||||||
|
3. Document automatic dependencies inline
|
||||||
|
4. Provide mode-specific quick-start templates
|
||||||
|
|
||||||
|
### Phase 4: Documentation Updates (v0.7.0)
|
||||||
|
|
||||||
|
**Files to modify**:
|
||||||
|
- `docs/configuration.md` - Lead with consolidated approach
|
||||||
|
- `docs/authentication.md` - Update mode guidance with `MCP_DEPLOYMENT_MODE`
|
||||||
|
- `docs/troubleshooting.md` - Add consolidation troubleshooting section
|
||||||
|
- `docs/configuration-migration-v2.md` - Expand with comprehensive examples
|
||||||
|
- `docs/ADR-020-deployment-modes-and-configuration-validation.md` - Update configuration matrix
|
||||||
|
- All other ADRs - Update variable references
|
||||||
|
|
||||||
|
**Key changes**:
|
||||||
|
1. Update all examples to use new variable names
|
||||||
|
2. Add before/after migration examples
|
||||||
|
3. Document automatic dependency resolution
|
||||||
|
4. Add mode selection decision tree diagram
|
||||||
|
|
||||||
|
## Validation Strategy
|
||||||
|
|
||||||
|
### Test Coverage Requirements
|
||||||
|
|
||||||
|
**Backward compatibility tests**:
|
||||||
|
- Old variable names still work (ENABLE_OFFLINE_ACCESS, VECTOR_SYNC_ENABLED)
|
||||||
|
- New variable names work (ENABLE_BACKGROUND_OPERATIONS, ENABLE_SEMANTIC_SEARCH)
|
||||||
|
- Setting both old and new triggers deprecation warning but works correctly
|
||||||
|
- All 41 existing config validation tests pass
|
||||||
|
|
||||||
|
**Auto-enablement tests**:
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=true` in OAuth mode → `enable_background_operations=true`
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=true` in single-user mode → `enable_background_operations=false` (not needed)
|
||||||
|
- `ENABLE_SEMANTIC_SEARCH=false` → `enable_background_operations=false` (unless explicitly set)
|
||||||
|
|
||||||
|
**Mode selection tests**:
|
||||||
|
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
|
||||||
|
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
|
||||||
|
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
**Immediate** (v0.6.0 release):
|
||||||
|
- Zero breaking changes in existing deployments
|
||||||
|
- All 41 config validation tests pass
|
||||||
|
- New users report clearer configuration process
|
||||||
|
|
||||||
|
**Medium-term** (6 months after v0.6.0):
|
||||||
|
- 80% of new deployments use new variable names
|
||||||
|
- Mode selection errors decrease by 50%
|
||||||
|
- Support requests about configuration decrease
|
||||||
|
|
||||||
|
**Long-term** (12+ months):
|
||||||
|
- 90% of deployments migrated to new names
|
||||||
|
- Old variable names can be safely removed in v1.0.0
|
||||||
|
- Configuration-related issues in issue tracker decrease
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: Just Rename Variables
|
||||||
|
|
||||||
|
**Rejected**: User feedback: "There's no reason to just rename variables without consolidating functionality"
|
||||||
|
|
||||||
|
This would make names clearer but wouldn't reduce the number of variables users need to set. The real problem is requiring users to set both ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED when they just want semantic search.
|
||||||
|
|
||||||
|
### Alternative 2: Remove ENABLE_OFFLINE_ACCESS Entirely
|
||||||
|
|
||||||
|
**Rejected**: Advanced users need background operations without semantic search
|
||||||
|
|
||||||
|
Some deployments might want background token storage for future features (background Deck sync, background Calendar sync, etc.) without enabling semantic search. Keeping ENABLE_BACKGROUND_OPERATIONS (renamed) allows this.
|
||||||
|
|
||||||
|
### Alternative 3: Always Auto-Enable Background Operations
|
||||||
|
|
||||||
|
**Rejected**: Single-user mode doesn't need background token storage
|
||||||
|
|
||||||
|
Auto-enablement is only needed in multi-user modes. Single-user mode uses a shared client with BasicAuth, so background token storage is unnecessary. Always enabling it would waste resources and create confusing log messages.
|
||||||
|
|
||||||
|
### Alternative 4: Require All New Names Immediately
|
||||||
|
|
||||||
|
**Rejected**: Breaking change would affect all existing deployments
|
||||||
|
|
||||||
|
Forcing migration to new variable names in v0.6.0 would break every existing deployment. Supporting both old and new names with deprecation warnings provides a smooth migration path.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-020: Deployment Modes and Configuration Validation](ADR-020-deployment-modes-and-configuration-validation.md)
|
||||||
|
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
|
||||||
|
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md)
|
||||||
|
- [Issue: Configuration complexity for multi-user semantic search](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/XXX)
|
||||||
|
|
||||||
|
## Migration Examples
|
||||||
|
|
||||||
|
### Example 1: Single-User BasicAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (optional migration):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Renamed
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
# Note: Background operations NOT auto-enabled (not needed in single-user mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 2: Multi-User OAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (simplified):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
# Note: ENABLE_OFFLINE_ACCESS no longer needed (auto-enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example 3: Multi-User OAuth WITHOUT Semantic Search
|
||||||
|
|
||||||
|
**Before**:
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_OFFLINE_ACCESS=true # For future background features
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
**After** (optional migration):
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
```
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# MCP 1.23.x DNS Rebinding Protection Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
|
||||||
|
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
transport_security = TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
|
||||||
|
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Was Happening
|
||||||
|
|
||||||
|
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
|
||||||
|
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
|
||||||
|
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
|
||||||
|
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
|
||||||
|
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
|
||||||
|
|
||||||
|
### Why `--host 0.0.0.0` Didn't Help
|
||||||
|
|
||||||
|
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
|
||||||
|
|
||||||
|
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
|
||||||
|
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
Modified `nextcloud_mcp_server/app.py`:
|
||||||
|
|
||||||
|
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
|
||||||
|
2. **Updated all three FastMCP initializations**:
|
||||||
|
- OAuth mode (line 1015)
|
||||||
|
- Smithery stateless mode (line 1030)
|
||||||
|
- BasicAuth mode (line 1040)
|
||||||
|
|
||||||
|
Each now includes:
|
||||||
|
```python
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### ✅ What This Fixes
|
||||||
|
|
||||||
|
- **Kubernetes deployments**: Requests with k8s service DNS names now work
|
||||||
|
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
|
||||||
|
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
|
||||||
|
- **Ingress controllers**: Requests via ingress hostnames now work
|
||||||
|
|
||||||
|
### 🔒 Security Considerations
|
||||||
|
|
||||||
|
DNS rebinding protection defends against attacks where:
|
||||||
|
1. Attacker controls a DNS domain (e.g., `evil.com`)
|
||||||
|
2. DNS initially resolves to attacker's IP
|
||||||
|
3. After victim's browser caches the origin, DNS changes to victim's localhost
|
||||||
|
4. Attacker's page can now make requests to victim's localhost services
|
||||||
|
|
||||||
|
**Why it's safe to disable for this deployment:**
|
||||||
|
|
||||||
|
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
|
||||||
|
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
|
||||||
|
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
|
||||||
|
4. **Host header validation inappropriate** for multi-tenant k8s environments
|
||||||
|
|
||||||
|
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
|
||||||
|
|
||||||
|
```python
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=[
|
||||||
|
"nextcloud-mcp-server.default.svc.cluster.local:*",
|
||||||
|
"mcp.example.com:*",
|
||||||
|
# Add all your expected Host header values
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- ✅ Ruff linting passes
|
||||||
|
- ✅ Type checking passes (pre-existing warnings unrelated)
|
||||||
|
- ✅ Module imports successfully
|
||||||
|
- ✅ Compatible with MCP 1.23.x
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
|
||||||
|
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
|
||||||
|
- Issue #373 (original report of k8s breakage)
|
||||||
|
- PR #382 (MCP 1.23.x upgrade)
|
||||||
@@ -0,0 +1,564 @@
|
|||||||
|
# Configuration Migration Guide v2
|
||||||
|
|
||||||
|
**Version:** v0.58.0
|
||||||
|
**Status:** Active
|
||||||
|
**Related ADR:** [ADR-021: Configuration Consolidation and Simplification](ADR-021-configuration-consolidation.md)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This guide helps you migrate from the old configuration variables to the new consolidated approach introduced in v0.58.0.
|
||||||
|
|
||||||
|
**Key Changes:**
|
||||||
|
- `VECTOR_SYNC_ENABLED` → `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
- `ENABLE_OFFLINE_ACCESS` → `ENABLE_BACKGROUND_OPERATIONS`
|
||||||
|
- New: `MCP_DEPLOYMENT_MODE` for explicit mode selection
|
||||||
|
- Automatic dependency resolution: semantic search auto-enables background operations
|
||||||
|
|
||||||
|
**Backward Compatibility:**
|
||||||
|
- Old variable names still work in v0.58.0+
|
||||||
|
- Deprecation warnings logged when old names used
|
||||||
|
- Old names will be removed in v1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Reference: Variable Name Changes
|
||||||
|
|
||||||
|
| Old Name | New Name | Status |
|
||||||
|
|----------|----------|--------|
|
||||||
|
| `VECTOR_SYNC_ENABLED` | `ENABLE_SEMANTIC_SEARCH` | Deprecated |
|
||||||
|
| `ENABLE_OFFLINE_ACCESS` | `ENABLE_BACKGROUND_OPERATIONS` | Deprecated |
|
||||||
|
| N/A (auto-detected) | `MCP_DEPLOYMENT_MODE` | New (optional) |
|
||||||
|
|
||||||
|
**Tuning parameters unchanged:**
|
||||||
|
- `VECTOR_SYNC_SCAN_INTERVAL` - Keep as-is
|
||||||
|
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Keep as-is
|
||||||
|
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Keep as-is
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Single-User BasicAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before (v0.57.x):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v0.58.0+):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=single_user_basic
|
||||||
|
|
||||||
|
# Updated variable name
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Previously VECTOR_SYNC_ENABLED
|
||||||
|
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Changed:**
|
||||||
|
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
- ✅ Added optional `MCP_DEPLOYMENT_MODE` for clarity
|
||||||
|
- ✅ Background operations NOT auto-enabled (not needed in single-user mode)
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
|
||||||
|
2. Optionally add `MCP_DEPLOYMENT_MODE=single_user_basic`
|
||||||
|
3. Restart server
|
||||||
|
4. Verify deprecation warnings are gone
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 2: Multi-User OAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before (v0.57.x):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# Both variables required - confusing!
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v0.58.0+ - Simplified):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# One variable does it all!
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Automatically enables background operations
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
|
||||||
|
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
|
||||||
|
# Background operations are auto-enabled by ENABLE_SEMANTIC_SEARCH
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Changed:**
|
||||||
|
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
|
||||||
|
- ✅ `ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes
|
||||||
|
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
- ✅ Added optional explicit mode declaration
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
|
||||||
|
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
|
||||||
|
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
|
||||||
|
4. Restart server
|
||||||
|
5. Check logs for confirmation: "Automatically enabled background operations for semantic search"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 3: Multi-User OAuth WITHOUT Semantic Search
|
||||||
|
|
||||||
|
**Before (v0.57.x):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# Enable background operations for future features
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v0.58.0+):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# Renamed for clarity
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true # Previously ENABLE_OFFLINE_ACCESS
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Changed:**
|
||||||
|
- ✅ Renamed `ENABLE_OFFLINE_ACCESS` to `ENABLE_BACKGROUND_OPERATIONS`
|
||||||
|
- ✅ Added optional explicit mode declaration
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Replace `ENABLE_OFFLINE_ACCESS=true` with `ENABLE_BACKGROUND_OPERATIONS=true`
|
||||||
|
2. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
|
||||||
|
3. Restart server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 4: Multi-User BasicAuth with Semantic Search
|
||||||
|
|
||||||
|
**Before (v0.57.x):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
|
||||||
|
# Both required - redundant
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v0.58.0+ - Simplified):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration
|
||||||
|
MCP_DEPLOYMENT_MODE=multi_user_basic
|
||||||
|
|
||||||
|
# One variable handles both!
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
|
||||||
|
|
||||||
|
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Changed:**
|
||||||
|
- ✅ Semantic search auto-enables background operations
|
||||||
|
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
|
||||||
|
- ✅ Clearer variable naming
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
|
||||||
|
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
|
||||||
|
3. Optionally add `MCP_DEPLOYMENT_MODE=multi_user_basic`
|
||||||
|
4. Restart server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Scenario 5: Token Exchange Mode with Semantic Search
|
||||||
|
|
||||||
|
**Before (v0.57.x):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
|
||||||
|
# Both required
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
TOKEN_EXCHANGE_CACHE_TTL=300
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
**After (v0.58.0+ - Simplified):**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_token_exchange
|
||||||
|
|
||||||
|
# One variable!
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
TOKEN_EXCHANGE_CACHE_TTL=300
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
**What Changed:**
|
||||||
|
- ✅ Semantic search auto-enables background operations
|
||||||
|
- ✅ Explicit mode declaration available
|
||||||
|
|
||||||
|
**Migration Steps:**
|
||||||
|
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
|
||||||
|
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
|
||||||
|
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_token_exchange`
|
||||||
|
4. Restart server
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding Automatic Dependency Resolution
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
In v0.58.0+, the server uses smart dependency resolution:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In multi-user modes (OAuth, Multi-User BasicAuth):
|
||||||
|
if ENABLE_SEMANTIC_SEARCH == true:
|
||||||
|
background_operations = automatically enabled
|
||||||
|
refresh_tokens = automatically requested
|
||||||
|
token_storage = required (TOKEN_ENCRYPTION_KEY, TOKEN_STORAGE_DB)
|
||||||
|
oauth_credentials = required (for app password retrieval)
|
||||||
|
```
|
||||||
|
|
||||||
|
**What this means:**
|
||||||
|
- ✅ Set `ENABLE_SEMANTIC_SEARCH=true`
|
||||||
|
- ✅ Provide required infrastructure (Qdrant, Ollama, encryption key)
|
||||||
|
- ✅ System automatically enables background operations
|
||||||
|
- ❌ No need to set `ENABLE_BACKGROUND_OPERATIONS` separately
|
||||||
|
|
||||||
|
### When Automatic Enablement Happens
|
||||||
|
|
||||||
|
| Deployment Mode | Semantic Search Enabled | Background Operations Auto-Enabled? |
|
||||||
|
|----------------|------------------------|-----------------------------------|
|
||||||
|
| Single-User BasicAuth | ✅ | ❌ No (not needed) |
|
||||||
|
| Multi-User BasicAuth | ✅ | ✅ Yes |
|
||||||
|
| OAuth Single-Audience | ✅ | ✅ Yes |
|
||||||
|
| OAuth Token Exchange | ✅ | ✅ Yes |
|
||||||
|
| Smithery Stateless | N/A (not supported) | N/A |
|
||||||
|
|
||||||
|
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
|
||||||
|
|
||||||
|
Only needed when you want background operations **without** semantic search:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: OAuth mode with background operations but NO semantic search
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# Explicitly enable background operations for future features
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# Semantic search disabled
|
||||||
|
ENABLE_SEMANTIC_SEARCH=false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Explicit Mode Selection
|
||||||
|
|
||||||
|
### Why Use MCP_DEPLOYMENT_MODE?
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Removes ambiguity about which mode is active
|
||||||
|
- ✅ Validation errors reference specific mode requirements
|
||||||
|
- ✅ Catches configuration mistakes early
|
||||||
|
- ✅ Self-documenting configuration
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
# Without explicit mode:
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
# Is this OAuth or Multi-User BasicAuth? Not immediately clear.
|
||||||
|
|
||||||
|
# With explicit mode:
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
# Clear: This is OAuth mode
|
||||||
|
```
|
||||||
|
|
||||||
|
### Valid Mode Values
|
||||||
|
|
||||||
|
| Mode Value | Description |
|
||||||
|
|-----------|-------------|
|
||||||
|
| `single_user_basic` | Single-user with username/password |
|
||||||
|
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
|
||||||
|
| `oauth_single_audience` | Multi-user OAuth (recommended) |
|
||||||
|
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
|
||||||
|
| `smithery` | Smithery platform deployment |
|
||||||
|
|
||||||
|
### Mode Detection Priority
|
||||||
|
|
||||||
|
When `MCP_DEPLOYMENT_MODE` is set:
|
||||||
|
1. ✅ Explicit mode is used
|
||||||
|
2. ✅ Server validates configuration matches explicit mode
|
||||||
|
3. ❌ Auto-detection is skipped
|
||||||
|
|
||||||
|
When `MCP_DEPLOYMENT_MODE` is NOT set:
|
||||||
|
1. ✅ Auto-detection runs (existing behavior)
|
||||||
|
2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation and Error Messages
|
||||||
|
|
||||||
|
### Old Validation (v0.57.x)
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: [multi_user_basic] ENABLE_OFFLINE_ACCESS is required when VECTOR_SYNC_ENABLED is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Problem:** User must understand internal dependency relationship
|
||||||
|
|
||||||
|
### New Validation (v0.58.0+)
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: [multi_user_basic] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefit:** Clear what's needed, no mention of internal ENABLE_BACKGROUND_OPERATIONS flag
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting Migration
|
||||||
|
|
||||||
|
### Issue: Deprecation Warning After Migration
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Check for `VECTOR_SYNC_ENABLED` in `.env` file
|
||||||
|
2. Replace with `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
3. Search for any scripts/CI configs using old name
|
||||||
|
4. Restart server
|
||||||
|
|
||||||
|
### Issue: Both Old and New Names Set
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Remove `VECTOR_SYNC_ENABLED` from `.env`
|
||||||
|
2. Keep `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
3. Restart server
|
||||||
|
|
||||||
|
### Issue: Missing Required Dependencies
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
When semantic search is enabled in multi-user modes, you need:
|
||||||
|
- `TOKEN_ENCRYPTION_KEY` - Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
|
||||||
|
- `TOKEN_STORAGE_DB` - Path to SQLite database (e.g., `/app/data/tokens.db`)
|
||||||
|
- `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` - For app password retrieval
|
||||||
|
|
||||||
|
### Issue: Unexpected Mode Detected
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
Server activates `oauth_single_audience` mode when you expected `multi_user_basic`
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Add explicit mode declaration:
|
||||||
|
```bash
|
||||||
|
MCP_DEPLOYMENT_MODE=multi_user_basic
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Your Migration
|
||||||
|
|
||||||
|
### Step 1: Verify Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set new variable names in .env
|
||||||
|
cat .env | grep -E "(ENABLE_SEMANTIC_SEARCH|ENABLE_BACKGROUND_OPERATIONS|MCP_DEPLOYMENT_MODE)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Check for Old Variable Names
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Should return nothing after migration
|
||||||
|
cat .env | grep -E "(VECTOR_SYNC_ENABLED|ENABLE_OFFLINE_ACCESS)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Start Server and Check Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start server
|
||||||
|
docker-compose up mcp
|
||||||
|
|
||||||
|
# Look for:
|
||||||
|
# 1. No deprecation warnings
|
||||||
|
# 2. Correct mode detected
|
||||||
|
# 3. Auto-enablement messages (if using semantic search in multi-user mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expected Log Output (Multi-User OAuth + Semantic Search):**
|
||||||
|
```
|
||||||
|
INFO: Using explicit deployment mode: oauth_single_audience
|
||||||
|
INFO: Automatically enabled background operations for semantic search in multi-user mode.
|
||||||
|
INFO: Vector sync enabled. Starting background scanner...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Verify Functionality
|
||||||
|
|
||||||
|
Test that existing features still work:
|
||||||
|
- [ ] Semantic search returns results
|
||||||
|
- [ ] Background indexing runs
|
||||||
|
- [ ] OAuth flow completes successfully
|
||||||
|
- [ ] Refresh tokens are stored/retrieved
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Start Templates
|
||||||
|
|
||||||
|
We provide mode-specific templates for new deployments:
|
||||||
|
|
||||||
|
| Template | Use Case |
|
||||||
|
|----------|----------|
|
||||||
|
| `env.sample.single-user` | Simplest setup |
|
||||||
|
| `env.sample.oauth-multi-user` | Recommended multi-user |
|
||||||
|
| `env.sample.oauth-advanced` | Token exchange mode |
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```bash
|
||||||
|
cp env.sample.oauth-multi-user .env
|
||||||
|
# Edit .env with your values
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Timeline and Support
|
||||||
|
|
||||||
|
| Version | Status | Old Variable Support |
|
||||||
|
|---------|--------|---------------------|
|
||||||
|
| v0.57.x | Stable | Old names only |
|
||||||
|
| v0.58.0 | Current | Both old and new (with warnings) |
|
||||||
|
| v1.0.0 | Breaking | New names only |
|
||||||
|
|
||||||
|
**Recommendation:** Migrate before v1.0.0 (12+ months minimum)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting Help
|
||||||
|
|
||||||
|
If you encounter issues during migration:
|
||||||
|
|
||||||
|
1. **Check the logs** - Look for deprecation warnings and error messages
|
||||||
|
2. **Review ADR-021** - See [docs/ADR-021-configuration-consolidation.md](ADR-021-configuration-consolidation.md)
|
||||||
|
3. **Use mode-specific templates** - See `env.sample.*` files
|
||||||
|
4. **File an issue** - Include your `.env` (redacted), logs, and mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**What You Need to Do:**
|
||||||
|
1. ✅ Rename `VECTOR_SYNC_ENABLED` → `ENABLE_SEMANTIC_SEARCH`
|
||||||
|
2. ✅ (Optional) Rename `ENABLE_OFFLINE_ACCESS` → `ENABLE_BACKGROUND_OPERATIONS`
|
||||||
|
3. ✅ (Recommended) Add `MCP_DEPLOYMENT_MODE` for clarity
|
||||||
|
4. ✅ Remove redundant settings (semantic search auto-enables background ops in multi-user modes)
|
||||||
|
5. ✅ Test your configuration
|
||||||
|
|
||||||
|
**What the Server Does Automatically:**
|
||||||
|
- ✅ Supports both old and new variable names
|
||||||
|
- ✅ Logs deprecation warnings for old names
|
||||||
|
- ✅ Auto-enables background operations when semantic search is enabled in multi-user modes
|
||||||
|
- ✅ Validates configuration and provides clear error messages
|
||||||
|
|
||||||
|
**Migration Timeline:**
|
||||||
|
- Now → v1.0.0: Both old and new names work
|
||||||
|
- v1.0.0+: Only new names supported
|
||||||
|
|
||||||
|
**Questions?** See [docs/configuration.md](configuration.md) or file an issue.
|
||||||
+129
-15
@@ -2,25 +2,82 @@
|
|||||||
|
|
||||||
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
|
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
|
||||||
|
|
||||||
|
> **Note:** Configuration was significantly simplified in v0.58.0. If you're upgrading from v0.57.x, see the [Configuration Migration Guide](configuration-migration-v2.md).
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Create a `.env` file based on `env.sample`:
|
We provide mode-specific configuration templates for quick setup:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# Choose a template based on your deployment mode:
|
||||||
|
cp env.sample.single-user .env # Simplest - one user, local dev
|
||||||
|
cp env.sample.oauth-multi-user .env # Recommended - multi-user OAuth
|
||||||
|
cp env.sample.oauth-advanced .env # Advanced - token exchange mode
|
||||||
|
|
||||||
|
# Or start from the full example:
|
||||||
cp env.sample .env
|
cp env.sample .env
|
||||||
|
|
||||||
# Edit .env with your Nextcloud details
|
# Edit .env with your Nextcloud details
|
||||||
```
|
```
|
||||||
|
|
||||||
Then choose your authentication mode:
|
Then choose your deployment mode:
|
||||||
|
|
||||||
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
|
- [Single-User BasicAuth](#single-user-basicauth-mode) - Simplest for personal instances
|
||||||
- [Basic Authentication Configuration](#basic-authentication-legacy)
|
- [Multi-User OAuth](#multi-user-oauth-modes) - Recommended for production
|
||||||
|
- [Deployment Mode Selection](#deployment-mode-selection) - Explicit mode declaration
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## OAuth2/OIDC Configuration
|
## Deployment Mode Selection
|
||||||
|
|
||||||
OAuth2/OIDC is the recommended authentication mode for production deployments.
|
**New in v0.58.0:** You can explicitly declare your deployment mode to remove ambiguity and catch configuration errors early.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# Optional but recommended
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
```
|
||||||
|
|
||||||
|
**Valid values:**
|
||||||
|
- `single_user_basic` - Single-user with username/password
|
||||||
|
- `multi_user_basic` - Multi-user with BasicAuth pass-through
|
||||||
|
- `oauth_single_audience` - Multi-user OAuth (recommended)
|
||||||
|
- `oauth_token_exchange` - Multi-user OAuth with token exchange
|
||||||
|
- `smithery` - Smithery platform deployment
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Clear which mode is active
|
||||||
|
- ✅ Better validation error messages
|
||||||
|
- ✅ Self-documenting configuration
|
||||||
|
- ✅ Catches configuration mistakes early
|
||||||
|
|
||||||
|
**Auto-detection:** If `MCP_DEPLOYMENT_MODE` is not set, the server auto-detects the mode based on other settings (existing behavior).
|
||||||
|
|
||||||
|
See [Authentication Modes](authentication.md) for detailed comparison of deployment modes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Single-User BasicAuth Mode
|
||||||
|
|
||||||
|
BasicAuth with a single user is the simplest deployment mode. Use for personal instances, local development, and testing.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
# Minimal single-user configuration
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration
|
||||||
|
MCP_DEPLOYMENT_MODE=single_user_basic
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **Security Notice:** BasicAuth stores credentials in environment variables and is less secure than OAuth. Use OAuth for production multi-user deployments.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-User OAuth Modes
|
||||||
|
|
||||||
|
OAuth2/OIDC is the recommended authentication mode for production multi-user deployments.
|
||||||
|
|
||||||
### Minimal Configuration (Auto-registration)
|
### Minimal Configuration (Auto-registration)
|
||||||
|
|
||||||
@@ -28,6 +85,9 @@ OAuth2/OIDC is the recommended authentication mode for production deployments.
|
|||||||
# .env file for OAuth with auto-registration
|
# .env file for OAuth with auto-registration
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
# Leave these EMPTY for OAuth mode
|
# Leave these EMPTY for OAuth mode
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
NEXTCLOUD_PASSWORD=
|
||||||
@@ -41,6 +101,9 @@ This minimal configuration uses dynamic client registration to automatically reg
|
|||||||
# .env file for OAuth with pre-configured client
|
# .env file for OAuth with pre-configured client
|
||||||
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
|
||||||
|
|
||||||
|
# Optional: Explicit mode declaration (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
# OAuth Client Credentials (optional - auto-registers if not provided)
|
# OAuth Client Credentials (optional - auto-registers if not provided)
|
||||||
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
@@ -110,8 +173,50 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
|||||||
|
|
||||||
## Semantic Search Configuration (Optional)
|
## Semantic Search Configuration (Optional)
|
||||||
|
|
||||||
|
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||||
|
|
||||||
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
|
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
|
||||||
|
|
||||||
|
### Quick Start
|
||||||
|
|
||||||
|
**Single-User Mode:**
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
|
||||||
|
# Enable semantic search
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
|
# Vector database
|
||||||
|
QDRANT_LOCATION=:memory:
|
||||||
|
|
||||||
|
# Embedding provider
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
**Multi-User OAuth Mode:**
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# Enable semantic search
|
||||||
|
# In multi-user modes, this AUTOMATICALLY enables background operations!
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
|
# Required for background operations (auto-enabled by semantic search)
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# Vector database
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
|
||||||
|
# Embedding provider
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** In multi-user modes (OAuth, Multi-User BasicAuth), enabling `ENABLE_SEMANTIC_SEARCH` automatically enables background operations and refresh token storage. You don't need to set `ENABLE_BACKGROUND_OPERATIONS` separately!
|
||||||
|
|
||||||
### Qdrant Vector Database Modes
|
### Qdrant Vector Database Modes
|
||||||
|
|
||||||
The server supports three Qdrant deployment modes:
|
The server supports three Qdrant deployment modes:
|
||||||
@@ -126,7 +231,7 @@ No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, t
|
|||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# No Qdrant configuration needed - defaults to :memory:
|
# No Qdrant configuration needed - defaults to :memory:
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -145,7 +250,7 @@ For single-instance deployments that need persistence without a separate Qdrant
|
|||||||
```dotenv
|
```dotenv
|
||||||
# Local persistent storage
|
# Local persistent storage
|
||||||
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -166,7 +271,7 @@ For production deployments with a dedicated Qdrant service:
|
|||||||
QDRANT_URL=http://qdrant:6333
|
QDRANT_URL=http://qdrant:6333
|
||||||
QDRANT_API_KEY=your-secret-api-key # Optional
|
QDRANT_API_KEY=your-secret-api-key # Optional
|
||||||
QDRANT_COLLECTION=nextcloud_content # Optional
|
QDRANT_COLLECTION=nextcloud_content # Optional
|
||||||
VECTOR_SYNC_ENABLED=true
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Pros:**
|
**Pros:**
|
||||||
@@ -283,13 +388,15 @@ Solutions:
|
|||||||
- Data corruption in Qdrant
|
- Data corruption in Qdrant
|
||||||
- Confusing error messages during indexing
|
- Confusing error messages during indexing
|
||||||
|
|
||||||
### Vector Sync Configuration
|
### Background Indexing Configuration
|
||||||
|
|
||||||
Control background indexing behavior:
|
Control background indexing behavior:
|
||||||
|
|
||||||
```dotenv
|
```dotenv
|
||||||
# Vector sync settings (ADR-007)
|
# Semantic search (ADR-007, ADR-021)
|
||||||
VECTOR_SYNC_ENABLED=true # Enable background indexing
|
ENABLE_SEMANTIC_SEARCH=true # Enable background indexing
|
||||||
|
|
||||||
|
# Tuning parameters (advanced - only modify if needed)
|
||||||
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
|
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
|
||||||
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
|
||||||
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
|
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
|
||||||
@@ -299,6 +406,8 @@ DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
|||||||
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
|
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** The `VECTOR_SYNC_*` tuning parameters keep their names as they're implementation details. Only the user-facing feature flag was renamed to `ENABLE_SEMANTIC_SEARCH`.
|
||||||
|
|
||||||
### Embedding Service Configuration
|
### Embedding Service Configuration
|
||||||
|
|
||||||
The server uses an embedding service to generate vector representations. Two options are available:
|
The server uses an embedding service to generate vector representations. Two options are available:
|
||||||
@@ -369,11 +478,11 @@ DOCUMENT_CHUNK_OVERLAP=100
|
|||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
|----------|----------|---------|-------------|
|
||||||
|
| `ENABLE_SEMANTIC_SEARCH` | ⚠️ Optional | `false` | Enable semantic search with background indexing (replaces `VECTOR_SYNC_ENABLED`) |
|
||||||
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
|
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
|
||||||
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
|
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
|
||||||
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
|
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
|
||||||
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
|
| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name |
|
||||||
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
|
|
||||||
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
|
||||||
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
|
||||||
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
|
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
|
||||||
@@ -383,6 +492,9 @@ DOCUMENT_CHUNK_OVERLAP=100
|
|||||||
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
|
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
|
||||||
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
|
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
|
||||||
|
|
||||||
|
**Deprecated variables (still functional):**
|
||||||
|
- `VECTOR_SYNC_ENABLED` - Use `ENABLE_SEMANTIC_SEARCH` instead (will be removed in v1.0.0)
|
||||||
|
|
||||||
### Docker Compose Example
|
### Docker Compose Example
|
||||||
|
|
||||||
Enable network mode Qdrant with docker-compose:
|
Enable network mode Qdrant with docker-compose:
|
||||||
@@ -392,7 +504,7 @@ services:
|
|||||||
mcp:
|
mcp:
|
||||||
environment:
|
environment:
|
||||||
- QDRANT_URL=http://qdrant:6333
|
- QDRANT_URL=http://qdrant:6333
|
||||||
- VECTOR_SYNC_ENABLED=true
|
- ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:latest
|
image: qdrant/qdrant:latest
|
||||||
@@ -545,6 +657,7 @@ uv run nextcloud-mcp-server --no-oauth \
|
|||||||
|
|
||||||
## See Also
|
## See Also
|
||||||
|
|
||||||
|
- [Configuration Migration Guide v2](configuration-migration-v2.md) - **New in v0.58.0:** Migrate from old variable names
|
||||||
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
|
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
|
||||||
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
|
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
|
||||||
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
|
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
|
||||||
@@ -553,3 +666,4 @@ uv run nextcloud-mcp-server --no-oauth \
|
|||||||
- [Running the Server](running.md) - Starting the server with different configurations
|
- [Running the Server](running.md) - Starting the server with different configurations
|
||||||
- [Troubleshooting](troubleshooting.md) - Common configuration issues
|
- [Troubleshooting](troubleshooting.md) - Common configuration issues
|
||||||
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
|
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
|
||||||
|
- [ADR-021](ADR-021-configuration-consolidation.md) - Configuration consolidation architecture decision
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
This document describes the database migration system for nextcloud-mcp-server's token storage database.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides:
|
||||||
|
|
||||||
|
- **Version Control**: Track schema changes in Git
|
||||||
|
- **Rollback Support**: Safely downgrade schema if needed
|
||||||
|
- **Audit Trail**: Migration files serve as schema changelog
|
||||||
|
- **Automated Upgrades**: Database schema updates automatically on startup
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Migration Strategy
|
||||||
|
|
||||||
|
The system handles three scenarios:
|
||||||
|
|
||||||
|
1. **New Database**: Runs migrations from scratch to create all tables
|
||||||
|
2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes)
|
||||||
|
3. **Alembic-Managed Database**: Upgrades to latest version automatically
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nextcloud-mcp-server/
|
||||||
|
├── alembic/ # Alembic migrations
|
||||||
|
│ ├── versions/ # Migration scripts
|
||||||
|
│ │ └── 20251217_2200_001_initial_schema.py
|
||||||
|
│ ├── env.py # Alembic environment
|
||||||
|
│ ├── script.py.mako # Migration template
|
||||||
|
│ └── README # Migration usage guide
|
||||||
|
├── alembic.ini # Alembic configuration
|
||||||
|
└── nextcloud_mcp_server/
|
||||||
|
├── auth/storage.py # Uses migrations on init
|
||||||
|
└── migrations.py # Migration utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Automatic Migration on Startup
|
||||||
|
|
||||||
|
Migrations run automatically when the server starts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run nextcloud-mcp-server
|
||||||
|
```
|
||||||
|
|
||||||
|
The `RefreshTokenStorage.initialize()` method:
|
||||||
|
1. Checks if database is Alembic-managed
|
||||||
|
2. Stamps pre-Alembic databases with initial revision
|
||||||
|
3. Upgrades to latest version
|
||||||
|
|
||||||
|
### Manual Migration Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Show current database version
|
||||||
|
uv run nextcloud-mcp-server db current
|
||||||
|
|
||||||
|
# Upgrade database to latest version
|
||||||
|
uv run nextcloud-mcp-server db upgrade
|
||||||
|
|
||||||
|
# Show migration history
|
||||||
|
uv run nextcloud-mcp-server db history
|
||||||
|
|
||||||
|
# Downgrade by one version (emergency use only)
|
||||||
|
uv run nextcloud-mcp-server db downgrade
|
||||||
|
|
||||||
|
# Specify custom database path
|
||||||
|
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`)
|
||||||
|
|
||||||
|
## Creating Migrations (Developers)
|
||||||
|
|
||||||
|
### Step 1: Create Migration File
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run nextcloud-mcp-server db migrate "add user preferences table"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions.
|
||||||
|
|
||||||
|
### Step 2: Write Migration SQL
|
||||||
|
|
||||||
|
Since we don't use SQLAlchemy models, write raw SQL:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Add user preferences table."""
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
theme TEXT DEFAULT 'light',
|
||||||
|
language TEXT DEFAULT 'en',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
op.execute("""
|
||||||
|
CREATE INDEX idx_user_preferences_user_id
|
||||||
|
ON user_preferences(user_id)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Remove user preferences table."""
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id")
|
||||||
|
op.execute("DROP TABLE IF EXISTS user_preferences")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Test Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test upgrade
|
||||||
|
uv run nextcloud-mcp-server db upgrade -d /tmp/test.db
|
||||||
|
|
||||||
|
# Verify schema
|
||||||
|
sqlite3 /tmp/test.db ".schema"
|
||||||
|
|
||||||
|
# Test downgrade
|
||||||
|
uv run nextcloud-mcp-server db downgrade -d /tmp/test.db
|
||||||
|
|
||||||
|
# Verify removal
|
||||||
|
sqlite3 /tmp/test.db ".schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Commit Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py
|
||||||
|
git commit -m "feat: add user preferences table migration"
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQLite Limitations
|
||||||
|
|
||||||
|
SQLite has limited `ALTER TABLE` support:
|
||||||
|
|
||||||
|
### Supported Operations
|
||||||
|
|
||||||
|
- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...`
|
||||||
|
- ✅ Rename table: `ALTER TABLE old RENAME TO new`
|
||||||
|
- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+)
|
||||||
|
|
||||||
|
### Unsupported Operations (Requires Table Recreation)
|
||||||
|
|
||||||
|
- ❌ Drop column
|
||||||
|
- ❌ Change column type
|
||||||
|
- ❌ Add constraints to existing columns
|
||||||
|
|
||||||
|
### Table Recreation Pattern
|
||||||
|
|
||||||
|
For complex schema changes:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Create new table with desired schema
|
||||||
|
op.execute("""
|
||||||
|
CREATE TABLE refresh_tokens_new (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_token BLOB NOT NULL,
|
||||||
|
new_field TEXT, -- New column
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Copy data from old table
|
||||||
|
op.execute("""
|
||||||
|
INSERT INTO refresh_tokens_new
|
||||||
|
(user_id, encrypted_token, expires_at, created_at)
|
||||||
|
SELECT user_id, encrypted_token, expires_at, created_at
|
||||||
|
FROM refresh_tokens
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Drop old table and rename new table
|
||||||
|
op.execute("DROP TABLE refresh_tokens")
|
||||||
|
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
|
||||||
|
|
||||||
|
# Recreate indexes
|
||||||
|
op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py`
|
||||||
|
- **Revision IDs**: Sequential numbers (`001`, `002`, `003`)
|
||||||
|
- **Descriptions**: Imperative mood ("add table", "remove column")
|
||||||
|
|
||||||
|
### Migration Guidelines
|
||||||
|
|
||||||
|
1. **Test Thoroughly**: Test both upgrade and downgrade paths
|
||||||
|
2. **Preserve Data**: Ensure data migration logic is correct
|
||||||
|
3. **Document Changes**: Add comments explaining complex operations
|
||||||
|
4. **Small Changes**: One logical change per migration
|
||||||
|
5. **No Breaking Changes**: Maintain backward compatibility when possible
|
||||||
|
|
||||||
|
### Downgrade Considerations
|
||||||
|
|
||||||
|
- **Data Loss**: Downgrade may lose data (dropped columns, tables)
|
||||||
|
- **Confirmation**: Downgrade command requires explicit confirmation
|
||||||
|
- **Testing**: Always test downgrade path before deploying
|
||||||
|
- **Emergency Only**: Use downgrades only for critical rollbacks
|
||||||
|
|
||||||
|
## Backward Compatibility
|
||||||
|
|
||||||
|
### Pre-Alembic Databases
|
||||||
|
|
||||||
|
Existing databases created before Alembic integration are automatically detected and stamped with revision `001`:
|
||||||
|
|
||||||
|
1. Server detects no `alembic_version` table
|
||||||
|
2. Checks if `refresh_tokens` table exists
|
||||||
|
3. If yes, stamps database with `001` (no schema changes)
|
||||||
|
4. Future updates use normal migration path
|
||||||
|
|
||||||
|
### Migration Path
|
||||||
|
|
||||||
|
```
|
||||||
|
Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ...
|
||||||
|
New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Migration Fails
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check current state
|
||||||
|
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||||
|
|
||||||
|
# View migration history
|
||||||
|
uv run nextcloud-mcp-server db history -d /path/to/tokens.db
|
||||||
|
|
||||||
|
# Manually inspect database
|
||||||
|
sqlite3 /path/to/tokens.db ".schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset to Initial State
|
||||||
|
|
||||||
|
**WARNING: This destroys all data!**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Downgrade to base (empty database)
|
||||||
|
uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base
|
||||||
|
|
||||||
|
# Upgrade to latest
|
||||||
|
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Corrupted Migration State
|
||||||
|
|
||||||
|
If `alembic_version` table is corrupted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Manually fix via SQL
|
||||||
|
sqlite3 /path/to/tokens.db
|
||||||
|
> DELETE FROM alembic_version;
|
||||||
|
> INSERT INTO alembic_version (version_num) VALUES ('001');
|
||||||
|
> .quit
|
||||||
|
|
||||||
|
# Verify and upgrade
|
||||||
|
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||||
|
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
### Pre-Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run migrations in test environment
|
||||||
|
export TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
uv run nextcloud-mcp-server db upgrade
|
||||||
|
|
||||||
|
# Verify current version
|
||||||
|
uv run nextcloud-mcp-server db current
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Deployment
|
||||||
|
|
||||||
|
Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`.
|
||||||
|
|
||||||
|
### Rollback Plan
|
||||||
|
|
||||||
|
1. Stop application
|
||||||
|
2. Backup database: `cp tokens.db tokens.db.backup`
|
||||||
|
3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX`
|
||||||
|
4. Deploy previous application version
|
||||||
|
5. Restart application
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
|
||||||
|
- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html)
|
||||||
|
- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001)
|
||||||
+189
-199
@@ -14,100 +14,10 @@ Before running the server:
|
|||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
Load your environment variables and start the server:
|
Start the server using Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Load environment variables from .env
|
# OAuth mode (recommended)
|
||||||
export $(grep -v '^#' .env | xargs)
|
|
||||||
|
|
||||||
# Start the server
|
|
||||||
uv run nextcloud-mcp-server
|
|
||||||
```
|
|
||||||
|
|
||||||
The server will start on `http://127.0.0.1:8000` by default.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running Locally
|
|
||||||
|
|
||||||
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
|
|
||||||
|
|
||||||
The CLI provides a simple interface with built-in defaults:
|
|
||||||
|
|
||||||
#### OAuth Mode
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
|
|
||||||
uv run nextcloud-mcp-server
|
|
||||||
|
|
||||||
# Explicitly force OAuth mode
|
|
||||||
uv run nextcloud-mcp-server --oauth
|
|
||||||
|
|
||||||
# OAuth with custom host and port
|
|
||||||
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
|
|
||||||
|
|
||||||
# OAuth with pre-configured client
|
|
||||||
uv run nextcloud-mcp-server --oauth \
|
|
||||||
--oauth-client-id abc123 \
|
|
||||||
--oauth-client-secret xyz789
|
|
||||||
|
|
||||||
# OAuth with specific apps only
|
|
||||||
uv run nextcloud-mcp-server --oauth \
|
|
||||||
--enable-app notes \
|
|
||||||
--enable-app calendar
|
|
||||||
```
|
|
||||||
|
|
||||||
#### BasicAuth Mode (Legacy)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
|
|
||||||
uv run nextcloud-mcp-server
|
|
||||||
|
|
||||||
# Explicitly force BasicAuth mode
|
|
||||||
uv run nextcloud-mcp-server --no-oauth
|
|
||||||
|
|
||||||
# BasicAuth with specific apps
|
|
||||||
uv run nextcloud-mcp-server --no-oauth \
|
|
||||||
--enable-app notes \
|
|
||||||
--enable-app webdav
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: Using uvicorn
|
|
||||||
|
|
||||||
For more control over server options (workers, reload, etc.):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Load environment variables
|
|
||||||
export $(grep -v '^#' .env | xargs)
|
|
||||||
|
|
||||||
# Run with uvicorn
|
|
||||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
|
||||||
--factory \
|
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port 8000 \
|
|
||||||
--reload # Enable auto-reload for development
|
|
||||||
```
|
|
||||||
|
|
||||||
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
|
|
||||||
|
|
||||||
### Method 3: Using Python Module
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Load environment variables
|
|
||||||
export $(grep -v '^#' .env | xargs)
|
|
||||||
|
|
||||||
# Run as Python module
|
|
||||||
python -m nextcloud_mcp_server.app --oauth --port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Running with Docker
|
|
||||||
|
|
||||||
### Basic Docker Run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# OAuth mode
|
|
||||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
@@ -116,11 +26,56 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
|||||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### Docker with Persistent OAuth Storage
|
The server will start on `http://127.0.0.1:8000` by default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running with Docker
|
||||||
|
|
||||||
|
### Basic Docker Run
|
||||||
|
|
||||||
|
#### OAuth Mode (Recommended)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
# OAuth with auto-registration
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
|
# OAuth with custom port
|
||||||
|
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
|
# OAuth with pre-configured client
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
-e NEXTCLOUD_OIDC_CLIENT_ID=abc123 \
|
||||||
|
-e NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789 \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
|
# OAuth with specific apps only
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--enable-app notes --enable-app calendar
|
||||||
|
```
|
||||||
|
|
||||||
|
#### BasicAuth Mode (Legacy)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# BasicAuth (requires NEXTCLOUD_USERNAME/PASSWORD in .env)
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
|
|
||||||
|
# BasicAuth with specific apps
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||||
|
--enable-app notes --enable-app webdav
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker with Persistent Token Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mount volume for persistent OAuth token storage
|
||||||
docker run -p 127.0.0.1:8000:8000 --env-file .env \
|
docker run -p 127.0.0.1:8000:8000 --env-file .env \
|
||||||
-v $(pwd)/.oauth:/app/.oauth \
|
-v $(pwd)/data:/app/data \
|
||||||
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -140,7 +95,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./oauth-storage:/app/.oauth
|
- ./data:/app/data # Persistent token storage
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -168,30 +123,39 @@ docker-compose down
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Bind to all interfaces (accessible from network)
|
# Bind to all interfaces (accessible from network)
|
||||||
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
docker run -p 0.0.0.0:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
# Bind to localhost only (default, more secure)
|
# Bind to localhost only (default, more secure)
|
||||||
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
# Use a different port
|
# Use a different port (map host port 8080 to container port 8000)
|
||||||
uv run nextcloud-mcp-server --port 8080
|
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
```
|
```
|
||||||
|
|
||||||
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
|
**Security Note:** Binding to `0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
|
||||||
|
|
||||||
### Transport Protocols
|
### Transport Protocols
|
||||||
|
|
||||||
The server supports multiple MCP transport protocols:
|
The server supports multiple MCP transport protocols:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Streamable HTTP (recommended)
|
# Streamable HTTP (default, recommended)
|
||||||
uv run nextcloud-mcp-server --transport streamable-http
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--transport streamable-http
|
||||||
|
|
||||||
# SSE - Server-Sent Events (default, deprecated)
|
# SSE - Server-Sent Events (deprecated)
|
||||||
uv run nextcloud-mcp-server --transport sse
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--transport sse
|
||||||
|
|
||||||
# HTTP
|
# HTTP
|
||||||
uv run nextcloud-mcp-server --transport http
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--transport http
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
@@ -201,10 +165,14 @@ uv run nextcloud-mcp-server --transport http
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Set log level (critical, error, warning, info, debug, trace)
|
# Set log level (critical, error, warning, info, debug, trace)
|
||||||
uv run nextcloud-mcp-server --log-level debug
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--log-level debug
|
||||||
|
|
||||||
# Production: use warning or error
|
# Production: use warning or error
|
||||||
uv run nextcloud-mcp-server --log-level warning
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--log-level warning
|
||||||
```
|
```
|
||||||
|
|
||||||
### Selective App Enablement
|
### Selective App Enablement
|
||||||
@@ -212,22 +180,26 @@ uv run nextcloud-mcp-server --log-level warning
|
|||||||
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
|
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
# Available apps: notes, tables, webdav, calendar, contacts, cookbook, deck
|
||||||
|
|
||||||
# Enable all apps (default)
|
# Enable all apps (default)
|
||||||
uv run nextcloud-mcp-server
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||||
|
|
||||||
# Enable only Notes
|
# Enable only Notes
|
||||||
uv run nextcloud-mcp-server --enable-app notes
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--enable-app notes
|
||||||
|
|
||||||
# Enable multiple apps
|
# Enable multiple apps
|
||||||
uv run nextcloud-mcp-server \
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
--enable-app notes \
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
--enable-app calendar \
|
--enable-app notes --enable-app calendar --enable-app contacts
|
||||||
--enable-app contacts
|
|
||||||
|
|
||||||
# Enable only WebDAV for file operations
|
# Enable only WebDAV for file operations
|
||||||
uv run nextcloud-mcp-server --enable-app webdav
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--enable-app webdav
|
||||||
```
|
```
|
||||||
|
|
||||||
**Use cases:**
|
**Use cases:**
|
||||||
@@ -240,24 +212,68 @@ uv run nextcloud-mcp-server --enable-app webdav
|
|||||||
|
|
||||||
## Development Mode
|
## Development Mode
|
||||||
|
|
||||||
For active development with auto-reload:
|
### Running for Development
|
||||||
|
|
||||||
|
For active development with auto-reload, mount your source code as a volume:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using uvicorn with reload
|
# Development mode with source code mounted
|
||||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
--factory \
|
-v $(pwd):/app \
|
||||||
--reload \
|
-v $(pwd)/data:/app/data \
|
||||||
--host 127.0.0.1 \
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
--port 8000 \
|
|
||||||
--log-level debug
|
--log-level debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Or use the CLI with reload flag:
|
For local development without Docker:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run nextcloud-mcp-server --reload --log-level debug
|
# Load environment variables
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
|
||||||
|
# Run the server with auto-reload
|
||||||
|
uv run nextcloud-mcp-server run --oauth --log-level debug
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI Subcommands
|
||||||
|
|
||||||
|
The `nextcloud-mcp-server` CLI has two main subcommands:
|
||||||
|
|
||||||
|
1. **`run`** - Start the MCP server (default command in Docker)
|
||||||
|
```bash
|
||||||
|
uv run nextcloud-mcp-server run --oauth --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **`db`** - Database migration management (Alembic)
|
||||||
|
```bash
|
||||||
|
# Show current migration revision
|
||||||
|
uv run nextcloud-mcp-server db current
|
||||||
|
|
||||||
|
# Upgrade to latest migration
|
||||||
|
uv run nextcloud-mcp-server db upgrade
|
||||||
|
|
||||||
|
# Show migration history
|
||||||
|
uv run nextcloud-mcp-server db history
|
||||||
|
|
||||||
|
# Create new migration (developers only)
|
||||||
|
uv run nextcloud-mcp-server db migrate "description of changes"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
|
||||||
|
Token storage uses **Alembic** for schema management:
|
||||||
|
|
||||||
|
- **Automatic migrations**: Database is upgraded automatically on server startup
|
||||||
|
- **Backward compatibility**: Pre-Alembic databases are automatically stamped with the initial revision
|
||||||
|
- **Migration files**: Located in `alembic/versions/`
|
||||||
|
- **For developers**: When changing the schema:
|
||||||
|
1. Create a migration: `uv run nextcloud-mcp-server db migrate "add new column"`
|
||||||
|
2. Edit the generated file in `alembic/versions/` to add SQL statements
|
||||||
|
3. Test upgrade: `uv run nextcloud-mcp-server db upgrade`
|
||||||
|
4. Test downgrade: `uv run nextcloud-mcp-server db downgrade`
|
||||||
|
|
||||||
|
See [Database Migrations Guide](database-migrations.md) for detailed information.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Connecting to the Server
|
## Connecting to the Server
|
||||||
@@ -266,15 +282,15 @@ uv run nextcloud-mcp-server --reload --log-level debug
|
|||||||
|
|
||||||
MCP Inspector is a browser-based tool for testing MCP servers:
|
MCP Inspector is a browser-based tool for testing MCP servers:
|
||||||
|
|
||||||
```bash
|
1. Start your MCP server using Docker (see above)
|
||||||
# Start MCP Inspector
|
2. Start MCP Inspector:
|
||||||
uv run mcp dev
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector
|
||||||
# In the browser:
|
```
|
||||||
# 1. Enter server URL: http://localhost:8000
|
3. In the browser:
|
||||||
# 2. Complete OAuth flow (if using OAuth)
|
- Enter server URL: `http://localhost:8000`
|
||||||
# 3. Explore tools and resources
|
- Complete OAuth flow (if using OAuth)
|
||||||
```
|
- Explore tools and resources
|
||||||
|
|
||||||
### Using MCP Clients
|
### Using MCP Clients
|
||||||
|
|
||||||
@@ -322,48 +338,13 @@ INFO Initializing Nextcloud client with BasicAuth
|
|||||||
|
|
||||||
### Running as a Background Service
|
### Running as a Background Service
|
||||||
|
|
||||||
#### Using systemd (Linux)
|
Use Docker Compose with `restart: unless-stopped` (see [Docker Compose section](#docker-compose) above).
|
||||||
|
|
||||||
Create `/etc/systemd/system/nextcloud-mcp.service`:
|
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Nextcloud MCP Server
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
User=your-user
|
|
||||||
WorkingDirectory=/path/to/nextcloud-mcp-server
|
|
||||||
EnvironmentFile=/path/to/.env
|
|
||||||
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=10
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
```
|
|
||||||
|
|
||||||
Enable and start:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo systemctl daemon-reload
|
|
||||||
sudo systemctl enable nextcloud-mcp
|
|
||||||
sudo systemctl start nextcloud-mcp
|
|
||||||
sudo systemctl status nextcloud-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Using Docker Compose
|
|
||||||
|
|
||||||
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
|
|
||||||
|
|
||||||
### Monitoring Logs
|
### Monitoring Logs
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Local installation with systemd
|
# Docker (find container name first)
|
||||||
sudo journalctl -u nextcloud-mcp -f
|
docker ps
|
||||||
|
|
||||||
# Docker
|
|
||||||
docker logs -f <container-name>
|
docker logs -f <container-name>
|
||||||
|
|
||||||
# Docker Compose
|
# Docker Compose
|
||||||
@@ -374,35 +355,38 @@ docker-compose logs -f mcp
|
|||||||
|
|
||||||
## Performance Tuning
|
## Performance Tuning
|
||||||
|
|
||||||
### Multiple Workers
|
|
||||||
|
|
||||||
For production deployments with higher load:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Using CLI (if supported)
|
|
||||||
uv run nextcloud-mcp-server --workers 4
|
|
||||||
|
|
||||||
# Using uvicorn
|
|
||||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
|
||||||
--factory \
|
|
||||||
--workers 4 \
|
|
||||||
--host 0.0.0.0 \
|
|
||||||
--port 8000
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production Settings
|
### Production Settings
|
||||||
|
|
||||||
```bash
|
For production deployments, use Docker Compose with the recommended settings:
|
||||||
# Recommended production configuration
|
|
||||||
uv run nextcloud-mcp-server \
|
```yaml
|
||||||
--oauth \
|
version: '3.8'
|
||||||
--host 127.0.0.1 \
|
|
||||||
--port 8000 \
|
services:
|
||||||
--log-level warning \
|
mcp:
|
||||||
--transport streamable-http \
|
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
--workers 2
|
command: --oauth --log-level warning --transport streamable-http
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 1G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Scaling with Multiple Replicas
|
||||||
|
|
||||||
|
For higher load, use Docker Swarm or Kubernetes. See the [Helm Chart](../helm/) for Kubernetes deployments.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -411,12 +395,18 @@ uv run nextcloud-mcp-server \
|
|||||||
|
|
||||||
Check logs for errors:
|
Check logs for errors:
|
||||||
```bash
|
```bash
|
||||||
uv run nextcloud-mcp-server --log-level debug
|
# View container logs
|
||||||
|
docker logs <container-name>
|
||||||
|
|
||||||
|
# Or run with debug logging
|
||||||
|
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||||
|
--log-level debug
|
||||||
```
|
```
|
||||||
|
|
||||||
Common issues:
|
Common issues:
|
||||||
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
|
- Environment variables not loaded - Check your `.env` file
|
||||||
- Port already in use - Try a different port with `--port`
|
- Port already in use - Use a different host port (e.g., `-p 127.0.0.1:8080:8000`)
|
||||||
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
|
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
|
||||||
|
|
||||||
### Can't connect to server
|
### Can't connect to server
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Status: Experimental**
|
> **Status: Experimental**
|
||||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||||
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
|
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
|
||||||
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
||||||
> - RAG answer generation requires MCP client sampling support
|
> - RAG answer generation requires MCP client sampling support
|
||||||
|
|
||||||
@@ -39,9 +39,9 @@ Semantic search enables:
|
|||||||
|
|
||||||
### Current Support
|
### Current Support
|
||||||
|
|
||||||
- **Supported Apps**: Notes (fully implemented)
|
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
|
||||||
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
|
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
|
||||||
- **Architecture**: Multi-app plugin system ready, awaiting implementation
|
- **Architecture**: Multi-app plugin system ready for additional apps
|
||||||
|
|
||||||
## System Components
|
## System Components
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,146 @@ This guide covers common issues and solutions for the Nextcloud MCP server.
|
|||||||
|
|
||||||
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
|
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
|
||||||
|
|
||||||
|
> **Upgrading from v0.57.x?** See the [Configuration Migration Guide](configuration-migration-v2.md) for help with new variable names.
|
||||||
|
|
||||||
|
## Configuration Issues (v0.58.0+)
|
||||||
|
|
||||||
|
### Issue: Deprecation warning for VECTOR_SYNC_ENABLED
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You're using the old variable name from v0.57.x.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
```bash
|
||||||
|
# In your .env file, replace:
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# With:
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
See [Configuration Migration Guide](configuration-migration-v2.md) for complete migration instructions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Deprecation warning for ENABLE_OFFLINE_ACCESS
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: ENABLE_OFFLINE_ACCESS is deprecated. Please use ENABLE_BACKGROUND_OPERATIONS instead.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You're using the old variable name from v0.57.x.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
**If you have semantic search enabled:**
|
||||||
|
```bash
|
||||||
|
# In multi-user modes, you can remove ENABLE_OFFLINE_ACCESS entirely!
|
||||||
|
# ENABLE_SEMANTIC_SEARCH automatically enables background operations
|
||||||
|
|
||||||
|
# Before (v0.57.x):
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# After (v0.58.0+):
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true # This is all you need!
|
||||||
|
```
|
||||||
|
|
||||||
|
**If you only want background operations (no semantic search):**
|
||||||
|
```bash
|
||||||
|
# Replace:
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
|
||||||
|
# With:
|
||||||
|
ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: "Invalid MCP_DEPLOYMENT_MODE"
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Use one of the valid mode values:
|
||||||
|
```bash
|
||||||
|
# Correct values:
|
||||||
|
MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/password
|
||||||
|
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
|
||||||
|
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
|
||||||
|
```
|
||||||
|
|
||||||
|
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Missing TOKEN_ENCRYPTION_KEY when semantic search enabled
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** In multi-user modes, semantic search automatically enables background operations, which require encrypted token storage.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Generate an encryption key and add required token storage configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate encryption key
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
|
||||||
|
# Add to .env:
|
||||||
|
TOKEN_ENCRYPTION_KEY=<generated-key>
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id # Required for app password retrieval
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why this happens:**
|
||||||
|
- v0.58.0+ automatically enables background operations when `ENABLE_SEMANTIC_SEARCH=true` in multi-user modes
|
||||||
|
- Background operations need encrypted refresh token storage
|
||||||
|
- This simplifies configuration but requires the encryption infrastructure
|
||||||
|
|
||||||
|
See [Configuration Guide - Semantic Search](configuration.md#semantic-search-configuration-optional) for details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Issue: Both old and new variable names set
|
||||||
|
|
||||||
|
**Symptom:**
|
||||||
|
```
|
||||||
|
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cause:** You have both the old and new variable names in your configuration.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
Remove the old variable name:
|
||||||
|
```bash
|
||||||
|
# Remove this line:
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
|
||||||
|
# Keep this line:
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
The server will use the new name and ignore the old one, but it's cleaner to remove the old variable entirely.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## OAuth Issues (Quick Reference)
|
## OAuth Issues (Quick Reference)
|
||||||
|
|
||||||
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
|
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
|
||||||
|
|||||||
+204
-166
@@ -1,198 +1,236 @@
|
|||||||
# Nextcloud Instance
|
# ============================================
|
||||||
|
# DEPLOYMENT MODE SELECTION
|
||||||
|
# ============================================
|
||||||
|
# Optional: Explicitly declare deployment mode (ADR-021)
|
||||||
|
# If not set, mode is auto-detected from other settings
|
||||||
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||||
|
# oauth_token_exchange, smithery
|
||||||
|
#
|
||||||
|
# Recommendation: Set this for clarity and to catch configuration errors early
|
||||||
|
#MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# COMMON SETTINGS (Required for all modes)
|
||||||
|
# ============================================
|
||||||
|
# Your Nextcloud instance URL (without trailing slash)
|
||||||
NEXTCLOUD_HOST=
|
NEXTCLOUD_HOST=
|
||||||
|
|
||||||
# ===== AUTHENTICATION MODE =====
|
# ============================================
|
||||||
# Choose ONE of the following:
|
# SINGLE-USER BASICAUTH MODE
|
||||||
|
# ============================================
|
||||||
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
|
# Simplest deployment - one user, credentials in environment
|
||||||
# - Requires Nextcloud OIDC app installed and configured
|
# Use for: Personal instances, local development, testing
|
||||||
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
|
#
|
||||||
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
|
# Required:
|
||||||
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
|
|
||||||
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_ID=
|
|
||||||
NEXTCLOUD_OIDC_CLIENT_SECRET=
|
|
||||||
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
|
||||||
|
|
||||||
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
|
|
||||||
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
|
|
||||||
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
|
||||||
#TOKEN_ENCRYPTION_KEY=
|
|
||||||
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
|
|
||||||
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
|
||||||
|
|
||||||
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
|
|
||||||
# Enable Progressive Consent mode (dual OAuth flows)
|
|
||||||
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
|
|
||||||
# When disabled: Uses existing hybrid flow (backward compatible)
|
|
||||||
|
|
||||||
# MCP Server OAuth Client Configuration
|
|
||||||
# The MCP server's own OAuth client credentials for Flow 2
|
|
||||||
# If not set, will use dynamic client registration
|
|
||||||
#MCP_SERVER_CLIENT_ID=
|
|
||||||
#MCP_SERVER_CLIENT_SECRET=
|
|
||||||
|
|
||||||
# Allowed MCP Client IDs (comma-separated list)
|
|
||||||
# Client IDs that are allowed to authenticate in Flow 1
|
|
||||||
# Examples: claude-desktop,continue-dev,zed-editor
|
|
||||||
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
|
|
||||||
|
|
||||||
# Token cache configuration for Token Broker Service
|
|
||||||
# Cache TTL in seconds (default: 300 = 5 minutes)
|
|
||||||
#TOKEN_CACHE_TTL=300
|
|
||||||
# Early refresh threshold in seconds (default: 30)
|
|
||||||
#TOKEN_CACHE_EARLY_REFRESH=30
|
|
||||||
|
|
||||||
# Option 2: Basic Authentication (LEGACY - Less Secure)
|
|
||||||
# - Requires username and password
|
|
||||||
# - Credentials stored in environment variables
|
|
||||||
# - Use only for backward compatibility or if OAuth unavailable
|
|
||||||
# - If these are set, OAuth mode is disabled
|
|
||||||
NEXTCLOUD_USERNAME=
|
NEXTCLOUD_USERNAME=
|
||||||
NEXTCLOUD_PASSWORD=
|
NEXTCLOUD_PASSWORD=
|
||||||
|
#
|
||||||
|
# Optional features (semantic search, document processing):
|
||||||
|
# See "Optional Features" section below
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Document Processing Configuration
|
# MULTI-USER BASICAUTH MODE
|
||||||
# ============================================
|
# ============================================
|
||||||
# Enable document processing (PDF, DOCX, images, etc.)
|
# Users provide credentials in request headers (pass-through)
|
||||||
# Set to false to disable all document processing
|
# Use for: Multi-user without OAuth, simple shared deployments
|
||||||
ENABLE_DOCUMENT_PROCESSING=false
|
#
|
||||||
|
# Required:
|
||||||
# Default processor to use when multiple are available
|
#ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
# Options: unstructured, tesseract, custom
|
#
|
||||||
DOCUMENT_PROCESSOR=unstructured
|
# Optional - Background Operations (for semantic search, future features):
|
||||||
|
# Enable background token storage using app passwords (via Astrolabe)
|
||||||
|
# Required for semantic search in multi-user mode
|
||||||
|
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
|
||||||
|
#ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
|
#TOKEN_ENCRYPTION_KEY=
|
||||||
|
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
#
|
||||||
|
# Optional features (semantic search, document processing):
|
||||||
|
# See "Optional Features" section below
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Unstructured.io Processor
|
# OAUTH SINGLE-AUDIENCE MODE (Recommended)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Enable Unstructured processor (requires unstructured service in docker-compose)
|
# Multi-user OAuth with single-audience tokens
|
||||||
# This is a cloud-based/API processor supporting many document types
|
# Use for: Multi-user production deployments, enhanced security
|
||||||
ENABLE_UNSTRUCTURED=false
|
# Tokens work for both MCP server and Nextcloud APIs (pass-through)
|
||||||
|
#
|
||||||
# Unstructured API endpoint
|
# Required: None (uses Dynamic Client Registration if credentials not provided)
|
||||||
UNSTRUCTURED_API_URL=http://unstructured:8000
|
#
|
||||||
|
# Optional - Pre-registered OAuth Client:
|
||||||
# Request timeout in seconds (default: 120)
|
# If you pre-register the client instead of using DCR:
|
||||||
# OCR operations can take 30-120 seconds for large documents
|
#NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
UNSTRUCTURED_TIMEOUT=120
|
#NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
|
#
|
||||||
# Parsing strategy: auto, fast, hi_res
|
# Optional - Background Operations (for semantic search, future features):
|
||||||
# - auto: Automatically choose based on document type
|
# Enable refresh token storage for offline access
|
||||||
# - fast: Fast parsing without OCR
|
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
|
||||||
# - hi_res: High-resolution with OCR (slowest, most accurate)
|
#ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
UNSTRUCTURED_STRATEGY=auto
|
#TOKEN_ENCRYPTION_KEY=
|
||||||
|
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
# OCR languages (comma-separated ISO 639-3 codes)
|
#
|
||||||
# Common: eng=English, deu=German, fra=French, spa=Spanish
|
# Optional - Custom OIDC Discovery:
|
||||||
UNSTRUCTURED_LANGUAGES=eng,deu
|
# Auto-detected from NEXTCLOUD_HOST if not set
|
||||||
|
#NEXTCLOUD_OIDC_DISCOVERY_URL=
|
||||||
# Progress reporting interval in seconds (default: 10)
|
#
|
||||||
# During long-running OCR operations, progress notifications are sent to the MCP client
|
# Optional - Custom Scopes:
|
||||||
# at this interval to prevent timeouts and provide status updates
|
# Default: openid profile email offline_access notes:* calendar:* contacts:* tables:* webdav:* deck:* cookbook:*
|
||||||
PROGRESS_INTERVAL=10
|
#NEXTCLOUD_OIDC_SCOPES=openid profile email notes:* calendar:*
|
||||||
|
#
|
||||||
|
# MCP Server URL (for OAuth redirects):
|
||||||
|
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
#
|
||||||
|
# Optional features (semantic search, document processing):
|
||||||
|
# See "Optional Features" section below
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Tesseract Processor (Local OCR)
|
# OAUTH TOKEN EXCHANGE MODE (Advanced)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Enable Tesseract processor (requires tesseract binary installed)
|
# Multi-user OAuth with RFC 8693 token exchange
|
||||||
# This is a local, lightweight OCR solution for images only
|
# Use for: Advanced deployments requiring separate MCP and Nextcloud tokens
|
||||||
ENABLE_TESSERACT=false
|
# MCP tokens are separate from Nextcloud tokens
|
||||||
|
#
|
||||||
# Path to tesseract executable (optional, auto-detected if in PATH)
|
# Required:
|
||||||
#TESSERACT_CMD=/usr/bin/tesseract
|
#ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
#
|
||||||
# OCR language (e.g., eng, deu, eng+deu for multiple)
|
# Optional - Pre-registered OAuth Client:
|
||||||
TESSERACT_LANG=eng
|
# If you pre-register the client instead of using DCR:
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_ID=
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_SECRET=
|
||||||
|
#
|
||||||
|
# Optional - Token Exchange Configuration:
|
||||||
|
# Cache TTL in seconds (default: 300 = 5 minutes)
|
||||||
|
#TOKEN_EXCHANGE_CACHE_TTL=300
|
||||||
|
#
|
||||||
|
# Optional - Background Operations:
|
||||||
|
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
|
||||||
|
#ENABLE_BACKGROUND_OPERATIONS=true
|
||||||
|
#TOKEN_ENCRYPTION_KEY=
|
||||||
|
#TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
#
|
||||||
|
# Optional - Custom OIDC Discovery:
|
||||||
|
#NEXTCLOUD_OIDC_DISCOVERY_URL=
|
||||||
|
#
|
||||||
|
# MCP Server URL (for OAuth redirects):
|
||||||
|
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
#
|
||||||
|
# Optional features (semantic search, document processing):
|
||||||
|
# See "Optional Features" section below
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Custom Processor (Your own API)
|
# SMITHERY STATELESS MODE
|
||||||
# ============================================
|
# ============================================
|
||||||
# Enable custom document processor via HTTP API
|
# Stateless multi-tenant deployment for Smithery platform
|
||||||
ENABLE_CUSTOM_PROCESSOR=false
|
# Configuration comes from session URL parameters
|
||||||
|
# No persistent storage, no OAuth, no vector sync
|
||||||
# Unique name for your processor
|
#
|
||||||
#CUSTOM_PROCESSOR_NAME=my_ocr
|
# Required: None (all config from session URL)
|
||||||
|
# This mode is activated automatically when deployed to Smithery
|
||||||
# Your custom processor API endpoint
|
|
||||||
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
|
||||||
|
|
||||||
# Optional API key for authentication
|
|
||||||
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
|
|
||||||
|
|
||||||
# Request timeout in seconds
|
|
||||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
|
||||||
|
|
||||||
# Comma-separated MIME types your processor supports
|
|
||||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Semantic Search & Vector Sync Configuration
|
# OPTIONAL FEATURES (All Deployment Modes)
|
||||||
# ============================================
|
# ============================================
|
||||||
# EXPERIMENTAL: Semantic search for Notes app (multi-app support planned)
|
|
||||||
# Requires: Qdrant vector database + Ollama embedding service
|
|
||||||
# Disabled by default
|
|
||||||
|
|
||||||
# Enable background vector indexing
|
# ===== SEMANTIC SEARCH =====
|
||||||
VECTOR_SYNC_ENABLED=false
|
# AI-powered semantic search across Nextcloud content
|
||||||
|
# Requires: Qdrant vector database + embedding provider (Ollama, Bedrock, or Simple fallback)
|
||||||
|
#
|
||||||
|
# Enable semantic search:
|
||||||
|
#ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
#
|
||||||
|
# Note for Multi-User Modes:
|
||||||
|
# ENABLE_SEMANTIC_SEARCH automatically enables background operations when needed
|
||||||
|
# No need to set ENABLE_BACKGROUND_OPERATIONS separately
|
||||||
|
# The server will automatically request refresh tokens and store them encrypted
|
||||||
|
#
|
||||||
|
# Vector Database - Choose ONE mode:
|
||||||
|
# 1. In-memory (default): Set neither QDRANT_URL nor QDRANT_LOCATION
|
||||||
|
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
|
||||||
|
# 3. Network: Set QDRANT_URL=http://qdrant:6333
|
||||||
|
#
|
||||||
|
#QDRANT_URL=http://qdrant:6333
|
||||||
|
#QDRANT_LOCATION=:memory:
|
||||||
|
#QDRANT_API_KEY=
|
||||||
|
#QDRANT_COLLECTION=nextcloud_content
|
||||||
|
#
|
||||||
|
# Embedding Provider - Choose ONE:
|
||||||
|
# 1. Ollama (recommended for local deployment):
|
||||||
|
#OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
#OLLAMA_VERIFY_SSL=true
|
||||||
|
#
|
||||||
|
# 2. Amazon Bedrock (for AWS deployments):
|
||||||
|
#AWS_REGION=us-east-1
|
||||||
|
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
|
||||||
|
# Optional: AWS credentials (uses credential chain if not set)
|
||||||
|
#AWS_ACCESS_KEY_ID=
|
||||||
|
#AWS_SECRET_ACCESS_KEY=
|
||||||
|
#
|
||||||
|
# 3. Simple (automatic fallback, no configuration needed)
|
||||||
|
# Uses basic in-memory embeddings if no provider configured
|
||||||
|
#
|
||||||
|
# Document Chunking:
|
||||||
|
# Configure how documents are split before embedding
|
||||||
|
#DOCUMENT_CHUNK_SIZE=512
|
||||||
|
#DOCUMENT_CHUNK_OVERLAP=50
|
||||||
|
|
||||||
|
# ===== SEMANTIC SEARCH TUNING =====
|
||||||
|
# Advanced parameters for vector sync background operations
|
||||||
|
# Only modify if you understand the implications
|
||||||
|
#
|
||||||
# Document scan interval in seconds (default: 300 = 5 minutes)
|
# Document scan interval in seconds (default: 300 = 5 minutes)
|
||||||
# How often to check for new/updated documents
|
|
||||||
#VECTOR_SYNC_SCAN_INTERVAL=300
|
#VECTOR_SYNC_SCAN_INTERVAL=300
|
||||||
|
#
|
||||||
# Concurrent indexing workers (default: 3)
|
# Concurrent indexing workers (default: 3)
|
||||||
# Number of parallel workers for embedding generation
|
|
||||||
#VECTOR_SYNC_PROCESSOR_WORKERS=3
|
#VECTOR_SYNC_PROCESSOR_WORKERS=3
|
||||||
|
#
|
||||||
# Max queued documents (default: 10000)
|
# Max queued documents (default: 10000)
|
||||||
# Maximum documents waiting to be processed
|
|
||||||
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
|
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
|
||||||
|
|
||||||
# ============================================
|
# ===== DOCUMENT PROCESSING =====
|
||||||
# Qdrant Vector Database Configuration
|
# Extract text from PDFs, images, DOCX, etc. for semantic search
|
||||||
# ============================================
|
# Disabled by default
|
||||||
# Choose ONE of three modes:
|
#
|
||||||
# 1. In-memory mode (default): Set neither QDRANT_URL nor QDRANT_LOCATION
|
#ENABLE_DOCUMENT_PROCESSING=false
|
||||||
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
|
#DOCUMENT_PROCESSOR=unstructured
|
||||||
# 3. Network mode: Set QDRANT_URL=http://qdrant:6333
|
#
|
||||||
|
# Unstructured.io Processor (recommended):
|
||||||
|
#ENABLE_UNSTRUCTURED=false
|
||||||
|
#UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||||
|
#UNSTRUCTURED_TIMEOUT=120
|
||||||
|
#UNSTRUCTURED_STRATEGY=auto
|
||||||
|
#UNSTRUCTURED_LANGUAGES=eng,deu
|
||||||
|
#PROGRESS_INTERVAL=10
|
||||||
|
#
|
||||||
|
# Tesseract OCR (lightweight, images only):
|
||||||
|
#ENABLE_TESSERACT=false
|
||||||
|
#TESSERACT_CMD=/usr/bin/tesseract
|
||||||
|
#TESSERACT_LANG=eng
|
||||||
|
#
|
||||||
|
# Custom Processor (your own API):
|
||||||
|
#ENABLE_CUSTOM_PROCESSOR=false
|
||||||
|
#CUSTOM_PROCESSOR_NAME=my_ocr
|
||||||
|
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
|
||||||
|
#CUSTOM_PROCESSOR_API_KEY=
|
||||||
|
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||||
|
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||||
|
|
||||||
# Network mode: URL to Qdrant service
|
# ===== SECURITY & ADVANCED =====
|
||||||
#QDRANT_URL=http://qdrant:6333
|
# Cookie security (browser UI)
|
||||||
|
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||||
# Local mode: Path to store vectors (use :memory: for in-memory)
|
#COOKIE_SECURE=true
|
||||||
#QDRANT_LOCATION=:memory:
|
|
||||||
|
|
||||||
# API key for network mode (optional)
|
|
||||||
#QDRANT_API_KEY=
|
|
||||||
|
|
||||||
# Collection name (optional - auto-generated if not set)
|
|
||||||
# Auto-generation format: {deployment-id}-{model-name}
|
|
||||||
# Allows safe model switching and multi-server deployments
|
|
||||||
#QDRANT_COLLECTION=nextcloud_content
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# Ollama Embedding Service Configuration
|
# DEPRECATED VARIABLES (Backward Compatibility)
|
||||||
# ============================================
|
# ============================================
|
||||||
# Ollama endpoint for embeddings (if not set, uses SimpleEmbeddingProvider fallback)
|
# These variables still work but will be removed in v1.0.0
|
||||||
#OLLAMA_BASE_URL=http://ollama:11434
|
# Please migrate to new names:
|
||||||
|
#
|
||||||
# Embedding model to use (default: nomic-embed-text, 768 dimensions)
|
# Old Name → New Name
|
||||||
# Changing this creates a new collection (requires re-embedding all documents)
|
# VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
|
||||||
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
# ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS
|
||||||
|
#
|
||||||
# Verify SSL certificates (default: true)
|
# Migration is optional - both old and new names work
|
||||||
#OLLAMA_VERIFY_SSL=true
|
# Deprecation warnings will be logged when old names are used
|
||||||
|
|
||||||
# ============================================
|
|
||||||
# Document Chunking Configuration
|
|
||||||
# ============================================
|
|
||||||
# Configure how documents are split before embedding
|
|
||||||
|
|
||||||
# Words per chunk (default: 512)
|
|
||||||
# Smaller chunks (256-384): More precise, less context, more storage
|
|
||||||
# Larger chunks (768-1024): More context, less precise, less storage
|
|
||||||
#DOCUMENT_CHUNK_SIZE=512
|
|
||||||
|
|
||||||
# Overlapping words between chunks (default: 50)
|
|
||||||
# Recommended: 10-20% of chunk size
|
|
||||||
# Preserves context across chunk boundaries
|
|
||||||
#DOCUMENT_CHUNK_OVERLAP=50
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# ============================================
|
||||||
|
# OAUTH TOKEN EXCHANGE QUICK START (Advanced)
|
||||||
|
# ============================================
|
||||||
|
# Advanced OAuth deployment with RFC 8693 token exchange
|
||||||
|
# Use for: Deployments requiring separate MCP and Nextcloud tokens
|
||||||
|
# Features: Dual-audience tokens, enhanced security boundaries
|
||||||
|
#
|
||||||
|
# Copy this file to .env and configure
|
||||||
|
|
||||||
|
# ===== REQUIRED SETTINGS =====
|
||||||
|
# Your Nextcloud instance URL (without trailing slash)
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
|
||||||
|
# Enable token exchange mode
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
|
||||||
|
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
|
||||||
|
# OAuth mode activates when these are NOT set
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
|
||||||
|
# Recommended for clarity
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_token_exchange
|
||||||
|
|
||||||
|
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
|
||||||
|
# If you pre-register the OAuth client instead of using DCR:
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
# MCP Server URL (for OAuth redirects)
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ===== OPTIONAL: TOKEN EXCHANGE TUNING =====
|
||||||
|
# Cache TTL for exchanged tokens (default: 300 seconds = 5 minutes)
|
||||||
|
TOKEN_EXCHANGE_CACHE_TTL=300
|
||||||
|
|
||||||
|
# ===== OPTIONAL: SEMANTIC SEARCH =====
|
||||||
|
# AI-powered semantic search with automatic background operation setup
|
||||||
|
#
|
||||||
|
# Note: ENABLE_SEMANTIC_SEARCH automatically enables background operations
|
||||||
|
# in token exchange mode, just like in OAuth single-audience mode
|
||||||
|
#
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
|
# Vector Database (required for semantic search)
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
|
||||||
|
# Embedding Provider (required for semantic search)
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Token Storage (required for background operations - auto-enabled by semantic search)
|
||||||
|
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# ===== OPTIONAL: DOCUMENT PROCESSING =====
|
||||||
|
# Extract text from PDFs, images, DOCX for semantic search
|
||||||
|
#ENABLE_DOCUMENT_PROCESSING=true
|
||||||
|
#ENABLE_UNSTRUCTURED=true
|
||||||
|
#UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||||
|
|
||||||
|
# ===== TOKEN EXCHANGE MODE EXPLANATION =====
|
||||||
|
# In this mode:
|
||||||
|
# 1. MCP clients authenticate with tokens scoped to "mcp-server" audience
|
||||||
|
# 2. Server exchanges MCP tokens for Nextcloud tokens on each request
|
||||||
|
# 3. Provides clear separation between MCP session and Nextcloud access
|
||||||
|
# 4. Enables fine-grained token lifecycle management
|
||||||
|
#
|
||||||
|
# When to use:
|
||||||
|
# - Strict security requirements (separate token contexts)
|
||||||
|
# - Complex multi-service architectures
|
||||||
|
# - Need independent token expiration policies
|
||||||
|
#
|
||||||
|
# When NOT to use:
|
||||||
|
# - Simple deployments (use oauth_single_audience instead)
|
||||||
|
# - High-performance requirements (token exchange adds latency)
|
||||||
|
|
||||||
|
# For more configuration options, see env.sample
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# ============================================
|
||||||
|
# OAUTH MULTI-USER QUICK START (Recommended)
|
||||||
|
# ============================================
|
||||||
|
# Multi-user deployment with OAuth authentication
|
||||||
|
# Use for: Multi-user production deployments, enhanced security
|
||||||
|
# Features: Single-audience tokens, automatic client registration (DCR)
|
||||||
|
#
|
||||||
|
# Copy this file to .env and configure
|
||||||
|
|
||||||
|
# ===== REQUIRED SETTINGS =====
|
||||||
|
# Your Nextcloud instance URL (without trailing slash)
|
||||||
|
NEXTCLOUD_HOST=https://nextcloud.example.com
|
||||||
|
|
||||||
|
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
|
||||||
|
# OAuth mode activates when these are NOT set
|
||||||
|
NEXTCLOUD_USERNAME=
|
||||||
|
NEXTCLOUD_PASSWORD=
|
||||||
|
|
||||||
|
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
|
||||||
|
# Recommended for clarity
|
||||||
|
MCP_DEPLOYMENT_MODE=oauth_single_audience
|
||||||
|
|
||||||
|
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
|
||||||
|
# If you pre-register the OAuth client instead of using DCR:
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
|
||||||
|
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
|
||||||
|
|
||||||
|
# MCP Server URL (for OAuth redirects)
|
||||||
|
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# ===== OPTIONAL: SEMANTIC SEARCH (Recommended) =====
|
||||||
|
# AI-powered semantic search with automatic background operation setup
|
||||||
|
#
|
||||||
|
# When you enable semantic search in multi-user mode:
|
||||||
|
# 1. ENABLE_SEMANTIC_SEARCH automatically enables background operations
|
||||||
|
# 2. Server requests refresh tokens for offline indexing
|
||||||
|
# 3. Tokens are stored encrypted in TOKEN_STORAGE_DB
|
||||||
|
# 4. No need to set ENABLE_BACKGROUND_OPERATIONS separately!
|
||||||
|
#
|
||||||
|
ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
|
||||||
|
# Vector Database (required for semantic search)
|
||||||
|
QDRANT_URL=http://qdrant:6333
|
||||||
|
# OR for in-memory mode:
|
||||||
|
#QDRANT_LOCATION=:memory:
|
||||||
|
|
||||||
|
# Embedding Provider (required for semantic search)
|
||||||
|
# Option 1: Ollama (recommended for local deployment)
|
||||||
|
OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Option 2: Amazon Bedrock (for AWS deployments)
|
||||||
|
#AWS_REGION=us-east-1
|
||||||
|
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
|
||||||
|
|
||||||
|
# Token Storage (required for background operations - auto-enabled by semantic search)
|
||||||
|
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
|
||||||
|
TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# ===== OPTIONAL: DOCUMENT PROCESSING =====
|
||||||
|
# Extract text from PDFs, images, DOCX for semantic search
|
||||||
|
#ENABLE_DOCUMENT_PROCESSING=true
|
||||||
|
#ENABLE_UNSTRUCTURED=true
|
||||||
|
#UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||||
|
|
||||||
|
# ===== SUMMARY OF AUTO-ENABLEMENT =====
|
||||||
|
# With ENABLE_SEMANTIC_SEARCH=true in OAuth mode:
|
||||||
|
# ✅ Background operations enabled automatically
|
||||||
|
# ✅ Refresh token storage enabled automatically
|
||||||
|
# ✅ OAuth credentials required (DCR or pre-registered)
|
||||||
|
# ✅ Encryption key required for token storage
|
||||||
|
#
|
||||||
|
# You only need to set ENABLE_SEMANTIC_SEARCH and provide the required
|
||||||
|
# infrastructure (Qdrant, Ollama, encryption key). The rest is automatic!
|
||||||
|
|
||||||
|
# For more advanced configuration, see env.sample
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# ============================================
|
||||||
|
# SINGLE-USER BASICAUTH QUICK START
|
||||||
|
# ============================================
|
||||||
|
# Simplest deployment mode - one user, credentials in environment
|
||||||
|
# Use for: Personal instances, local development, testing
|
||||||
|
#
|
||||||
|
# Copy this file to .env and fill in your credentials
|
||||||
|
|
||||||
|
# ===== REQUIRED SETTINGS =====
|
||||||
|
# Your Nextcloud instance URL (without trailing slash)
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
|
||||||
|
# Your Nextcloud credentials
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password
|
||||||
|
|
||||||
|
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
|
||||||
|
# Recommended to avoid ambiguity
|
||||||
|
MCP_DEPLOYMENT_MODE=single_user_basic
|
||||||
|
|
||||||
|
# ===== OPTIONAL: SEMANTIC SEARCH =====
|
||||||
|
# Uncomment to enable AI-powered semantic search
|
||||||
|
# Requires: Qdrant + embedding provider (Ollama or Bedrock)
|
||||||
|
#
|
||||||
|
#ENABLE_SEMANTIC_SEARCH=true
|
||||||
|
#QDRANT_LOCATION=:memory:
|
||||||
|
#OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# ===== OPTIONAL: DOCUMENT PROCESSING =====
|
||||||
|
# Extract text from PDFs, images, DOCX for semantic search
|
||||||
|
#ENABLE_DOCUMENT_PROCESSING=true
|
||||||
|
#ENABLE_UNSTRUCTURED=true
|
||||||
|
#UNSTRUCTURED_API_URL=http://unstructured:8000
|
||||||
|
|
||||||
|
# That's it! Single-user mode is the simplest to configure.
|
||||||
|
# For more options, see env.sample
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Alembic environment configuration for nextcloud-mcp-server.
|
||||||
|
|
||||||
|
This module configures how Alembic runs database migrations for the
|
||||||
|
token storage database. It supports both online and offline migration modes.
|
||||||
|
|
||||||
|
Uses anyio for async operations, consistent with the project's async patterns.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logger = logging.getLogger("alembic.env")
|
||||||
|
|
||||||
|
# This is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Update script location to point to package location
|
||||||
|
# This allows alembic to find migrations when installed in site-packages
|
||||||
|
script_location = Path(__file__).parent
|
||||||
|
config.set_main_option("script_location", str(script_location))
|
||||||
|
|
||||||
|
# We don't use SQLAlchemy models, so target_metadata is None
|
||||||
|
# Migrations will be written manually using op.execute() for raw SQL
|
||||||
|
target_metadata = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database_url() -> str:
|
||||||
|
"""
|
||||||
|
Get the database URL from Alembic config or environment.
|
||||||
|
|
||||||
|
The URL can be set in alembic.ini or passed via -x database_url=...
|
||||||
|
when running Alembic commands.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Database URL (SQLite URL format)
|
||||||
|
"""
|
||||||
|
# Check if URL is passed via -x database_url=...
|
||||||
|
url = context.get_x_argument(as_dictionary=True).get("database_url")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
# Fall back to alembic.ini configuration
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
# Default to /app/data/tokens.db for Docker deployments
|
||||||
|
db_path = Path("/app/data/tokens.db")
|
||||||
|
url = f"sqlite+aiosqlite:///{db_path}"
|
||||||
|
logger.warning(
|
||||||
|
f"No database URL configured, using default: {url}. "
|
||||||
|
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..."
|
||||||
|
)
|
||||||
|
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL and not an Engine,
|
||||||
|
though an Engine is acceptable here as well. By skipping the
|
||||||
|
Engine creation we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
This mode is useful for generating SQL scripts without database access.
|
||||||
|
"""
|
||||||
|
url = get_database_url()
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
"""Execute migrations within a database connection."""
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
"""Run migrations in 'online' mode with async support.
|
||||||
|
|
||||||
|
In this scenario we create an async Engine and associate
|
||||||
|
a connection with the context.
|
||||||
|
"""
|
||||||
|
# Get database URL and update config
|
||||||
|
url = get_database_url()
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
|
||||||
|
# Create async engine
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool, # Don't pool connections for migrations
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
This function is called from storage.py's initialize() method via
|
||||||
|
anyio.to_thread.run_sync(), so it always runs in a worker thread
|
||||||
|
with its own event loop. We can safely use anyio.run() here.
|
||||||
|
"""
|
||||||
|
anyio.run(run_async_migrations)
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Initial schema for token storage database
|
||||||
|
|
||||||
|
This migration creates the initial database schema including:
|
||||||
|
- refresh_tokens: OAuth refresh tokens and user profiles
|
||||||
|
- audit_logs: Audit trail for security events
|
||||||
|
- oauth_clients: OAuth client credentials (DCR)
|
||||||
|
- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent)
|
||||||
|
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
|
||||||
|
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
|
||||||
|
|
||||||
|
Revision ID: 001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-12-17 22:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "001"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create initial database schema."""
|
||||||
|
|
||||||
|
# Refresh tokens table (OAuth mode only, for background jobs)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
encrypted_token BLOB NOT NULL,
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
-- ADR-004 Progressive Consent fields
|
||||||
|
flow_type TEXT DEFAULT 'hybrid',
|
||||||
|
token_audience TEXT DEFAULT 'nextcloud',
|
||||||
|
provisioned_at INTEGER,
|
||||||
|
provisioning_client_id TEXT,
|
||||||
|
scopes TEXT,
|
||||||
|
-- Browser session profile cache
|
||||||
|
user_profile TEXT,
|
||||||
|
profile_cached_at INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Audit logs table (both OAuth and BasicAuth modes)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
event TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
resource_type TEXT,
|
||||||
|
resource_id TEXT,
|
||||||
|
auth_method TEXT,
|
||||||
|
hostname TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index on audit logs for efficient queries
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
|
||||||
|
ON audit_logs(user_id, timestamp)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth client credentials storage (OAuth mode only)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
client_id TEXT UNIQUE NOT NULL,
|
||||||
|
encrypted_client_secret BLOB NOT NULL,
|
||||||
|
client_id_issued_at INTEGER NOT NULL,
|
||||||
|
client_secret_expires_at INTEGER NOT NULL,
|
||||||
|
redirect_uris TEXT NOT NULL,
|
||||||
|
encrypted_registration_access_token BLOB,
|
||||||
|
registration_client_uri TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# OAuth flow sessions (ADR-004 Progressive Consent)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||||
|
session_id TEXT PRIMARY KEY,
|
||||||
|
client_id TEXT,
|
||||||
|
client_redirect_uri TEXT NOT NULL,
|
||||||
|
state TEXT,
|
||||||
|
code_challenge TEXT,
|
||||||
|
code_challenge_method TEXT,
|
||||||
|
mcp_authorization_code TEXT UNIQUE,
|
||||||
|
idp_access_token TEXT,
|
||||||
|
idp_refresh_token TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
expires_at INTEGER NOT NULL,
|
||||||
|
-- ADR-004 Progressive Consent fields
|
||||||
|
flow_type TEXT DEFAULT 'hybrid',
|
||||||
|
requested_scopes TEXT,
|
||||||
|
granted_scopes TEXT,
|
||||||
|
is_provisioning BOOLEAN DEFAULT FALSE
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Index for MCP authorization code lookups
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code
|
||||||
|
ON oauth_sessions(mcp_authorization_code)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy schema version tracking table
|
||||||
|
# NOTE: This is deprecated in favor of Alembic's alembic_version table
|
||||||
|
# Kept for backward compatibility with pre-Alembic databases
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Registered webhooks tracking (both BasicAuth and OAuth modes)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS registered_webhooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
webhook_id INTEGER NOT NULL UNIQUE,
|
||||||
|
preset_id TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Indexes for efficient webhook queries
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhooks_preset
|
||||||
|
ON registered_webhooks(preset_id)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhooks_created
|
||||||
|
ON registered_webhooks(created_at)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop all tables and indexes.
|
||||||
|
|
||||||
|
WARNING: This will destroy all data in the database!
|
||||||
|
Use with extreme caution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Drop indexes first
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_webhooks_created")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_webhooks_preset")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code")
|
||||||
|
op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp")
|
||||||
|
|
||||||
|
# Drop tables
|
||||||
|
op.execute("DROP TABLE IF EXISTS registered_webhooks")
|
||||||
|
op.execute("DROP TABLE IF EXISTS schema_version")
|
||||||
|
op.execute("DROP TABLE IF EXISTS oauth_sessions")
|
||||||
|
op.execute("DROP TABLE IF EXISTS oauth_clients")
|
||||||
|
op.execute("DROP TABLE IF EXISTS audit_logs")
|
||||||
|
op.execute("DROP TABLE IF EXISTS refresh_tokens")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
"""Management API for Nextcloud MCP Server.
|
||||||
|
|
||||||
|
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||||
|
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||||
|
authentication via the UnifiedTokenVerifier.
|
||||||
|
"""
|
||||||
File diff suppressed because it is too large
Load Diff
+629
-85
@@ -1,11 +1,12 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import traceback
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional, cast
|
||||||
|
|
||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ import httpx
|
|||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
@@ -40,10 +42,14 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
|||||||
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,
|
||||||
get_deployment_mode,
|
|
||||||
get_document_processor_config,
|
get_document_processor_config,
|
||||||
get_settings,
|
get_settings,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.config_validators import (
|
||||||
|
AuthMode,
|
||||||
|
get_mode_summary,
|
||||||
|
validate_configuration,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.observability import (
|
from nextcloud_mcp_server.observability import (
|
||||||
@@ -60,6 +66,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_contacts_tools,
|
configure_contacts_tools,
|
||||||
configure_cookbook_tools,
|
configure_cookbook_tools,
|
||||||
configure_deck_tools,
|
configure_deck_tools,
|
||||||
|
configure_news_tools,
|
||||||
configure_notes_tools,
|
configure_notes_tools,
|
||||||
configure_semantic_tools,
|
configure_semantic_tools,
|
||||||
configure_sharing_tools,
|
configure_sharing_tools,
|
||||||
@@ -349,6 +356,52 @@ def get_smithery_session_config() -> dict | None:
|
|||||||
return _smithery_session_config.get()
|
return _smithery_session_config.get()
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthMiddleware:
|
||||||
|
"""Middleware to extract BasicAuth credentials from Authorization header.
|
||||||
|
|
||||||
|
For multi-user BasicAuth pass-through mode, this middleware extracts
|
||||||
|
username/password from the Authorization: Basic header and stores them
|
||||||
|
in the request state for use by the context layer.
|
||||||
|
|
||||||
|
The credentials are NOT stored persistently - they are passed through
|
||||||
|
directly to Nextcloud APIs for each request (stateless).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self, scope: StarletteScope, receive: Receive, send: Send
|
||||||
|
) -> None:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
# Extract Authorization header
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
auth_header = headers.get(b"authorization", b"")
|
||||||
|
|
||||||
|
if auth_header.startswith(b"Basic "):
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Decode base64(username:password)
|
||||||
|
encoded = auth_header[6:] # Skip "Basic "
|
||||||
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
|
||||||
|
# Store in request state
|
||||||
|
scope.setdefault("state", {})
|
||||||
|
scope["state"]["basic_auth"] = {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
f"BasicAuth credentials extracted for user: {username}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract BasicAuth credentials: {e}")
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
class SmitheryConfigMiddleware:
|
class SmitheryConfigMiddleware:
|
||||||
"""Middleware to extract Smithery config from URL query parameters.
|
"""Middleware to extract Smithery config from URL query parameters.
|
||||||
|
|
||||||
@@ -421,41 +474,6 @@ async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppCon
|
|||||||
logger.info("Shutting down Smithery stateless mode")
|
logger.info("Shutting down Smithery stateless mode")
|
||||||
|
|
||||||
|
|
||||||
def is_oauth_mode() -> bool:
|
|
||||||
"""
|
|
||||||
Determine if OAuth mode should be used.
|
|
||||||
|
|
||||||
OAuth mode is enabled when:
|
|
||||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
|
||||||
- AND we are NOT in Smithery stateless mode
|
|
||||||
- Or explicitly enabled via configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if OAuth mode, False if BasicAuth mode
|
|
||||||
"""
|
|
||||||
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
|
|
||||||
# It's not OAuth mode even though env credentials aren't set
|
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
|
||||||
logger.info(
|
|
||||||
"BasicAuth mode (Smithery stateless - credentials from session config)"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
|
||||||
|
|
||||||
# If both username and password are set, use BasicAuth
|
|
||||||
if username and password:
|
|
||||||
logger.info(
|
|
||||||
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def load_oauth_client_credentials(
|
async def load_oauth_client_credentials(
|
||||||
nextcloud_host: str, registration_endpoint: str | None
|
nextcloud_host: str, registration_endpoint: str | None
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -514,7 +532,7 @@ async def load_oauth_client_credentials(
|
|||||||
# and the authorization server will limit them to these allowed scopes.
|
# and the authorization server will limit them to these allowed scopes.
|
||||||
#
|
#
|
||||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||||
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo: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"
|
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo: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 news:read news:write"
|
||||||
|
|
||||||
# Add offline_access scope if refresh tokens are enabled
|
# Add offline_access scope if refresh tokens are enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
@@ -576,17 +594,31 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
"""
|
"""
|
||||||
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
||||||
|
|
||||||
Creates a single Nextcloud client with basic authentication
|
For single-user mode: Creates a single Nextcloud client with basic authentication
|
||||||
that is shared across all requests within a session.
|
that is shared across all requests within a session.
|
||||||
|
|
||||||
|
For multi-user mode: No shared client - clients created per-request by BasicAuthMiddleware.
|
||||||
|
|
||||||
Note: Background tasks (scanner, processor) are started at server level
|
Note: Background tasks (scanner, processor) are started at server level
|
||||||
in starlette_lifespan, not here. This lifespan runs per-session.
|
in starlette_lifespan, not here. This lifespan runs per-session.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting MCP session in BasicAuth mode")
|
settings = get_settings()
|
||||||
logger.info("Creating Nextcloud client with BasicAuth")
|
is_multi_user = settings.enable_multi_user_basic_auth
|
||||||
|
|
||||||
client = NextcloudClient.from_env()
|
logger.info(
|
||||||
logger.info("Client initialization complete")
|
f"Starting MCP session in {'multi-user' if is_multi_user else 'single-user'} BasicAuth mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only create shared client for single-user mode
|
||||||
|
client = None
|
||||||
|
if not is_multi_user:
|
||||||
|
logger.info("Creating shared Nextcloud client with BasicAuth")
|
||||||
|
client = NextcloudClient.from_env()
|
||||||
|
logger.info("Client initialization complete")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Multi-user mode - clients created per-request from BasicAuth headers"
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize persistent storage (for webhook tracking and future features)
|
# Initialize persistent storage (for webhook tracking and future features)
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
@@ -602,7 +634,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
# Include vector sync state from module singleton (set by starlette_lifespan)
|
# Include vector sync state from module singleton (set by starlette_lifespan)
|
||||||
try:
|
try:
|
||||||
yield AppContext(
|
yield AppContext(
|
||||||
client=client,
|
client=client, # type: ignore[arg-type] # None in multi-user mode
|
||||||
storage=storage,
|
storage=storage,
|
||||||
document_send_stream=_vector_sync_state.document_send_stream,
|
document_send_stream=_vector_sync_state.document_send_stream,
|
||||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||||
@@ -611,7 +643,8 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
logger.info("Shutting down BasicAuth session")
|
logger.info("Shutting down BasicAuth session")
|
||||||
await client.close()
|
if client is not None:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
async def setup_oauth_config():
|
async def setup_oauth_config():
|
||||||
@@ -674,6 +707,29 @@ async def setup_oauth_config():
|
|||||||
logger.info(f"OIDC_JWKS_URI override: {jwks_uri} → {jwks_uri_override}")
|
logger.info(f"OIDC_JWKS_URI override: {jwks_uri} → {jwks_uri_override}")
|
||||||
jwks_uri = jwks_uri_override
|
jwks_uri = jwks_uri_override
|
||||||
|
|
||||||
|
# Rewrite discovered endpoint URLs from public issuer to internal host
|
||||||
|
# This is needed when OIDC discovery returns public URLs (e.g., http://localhost:8080)
|
||||||
|
# but the server needs to access them via internal docker network (e.g., http://app:80)
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
issuer_parsed = urlparse(issuer)
|
||||||
|
nextcloud_parsed = urlparse(nextcloud_host)
|
||||||
|
issuer_base = f"{issuer_parsed.scheme}://{issuer_parsed.netloc}"
|
||||||
|
nextcloud_base = f"{nextcloud_parsed.scheme}://{nextcloud_parsed.netloc}"
|
||||||
|
|
||||||
|
if issuer_base != nextcloud_base:
|
||||||
|
logger.info(f"Rewriting OIDC endpoints: {issuer_base} → {nextcloud_base}")
|
||||||
|
|
||||||
|
def rewrite_url(url: str | None) -> str | None:
|
||||||
|
if url and url.startswith(issuer_base):
|
||||||
|
return url.replace(issuer_base, nextcloud_base, 1)
|
||||||
|
return url
|
||||||
|
|
||||||
|
userinfo_uri = rewrite_url(userinfo_uri) or userinfo_uri
|
||||||
|
jwks_uri = rewrite_url(jwks_uri)
|
||||||
|
introspection_uri = rewrite_url(introspection_uri)
|
||||||
|
registration_endpoint = rewrite_url(registration_endpoint)
|
||||||
|
|
||||||
logger.info("OIDC endpoints discovered:")
|
logger.info("OIDC endpoints discovered:")
|
||||||
logger.info(f" Issuer: {issuer}")
|
logger.info(f" Issuer: {issuer}")
|
||||||
logger.info(f" Userinfo: {userinfo_uri}")
|
logger.info(f" Userinfo: {userinfo_uri}")
|
||||||
@@ -685,8 +741,6 @@ async def setup_oauth_config():
|
|||||||
# Auto-detect provider mode based on issuer
|
# Auto-detect provider mode based on issuer
|
||||||
# External IdP mode: issuer doesn't match Nextcloud host
|
# External IdP mode: issuer doesn't match Nextcloud host
|
||||||
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
|
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
def normalize_url(url: str) -> str:
|
def normalize_url(url: str) -> str:
|
||||||
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
|
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
|
||||||
parsed = urlparse(url)
|
parsed = urlparse(url)
|
||||||
@@ -702,7 +756,16 @@ async def setup_oauth_config():
|
|||||||
issuer_normalized = normalize_url(issuer)
|
issuer_normalized = normalize_url(issuer)
|
||||||
nextcloud_normalized = normalize_url(nextcloud_host)
|
nextcloud_normalized = normalize_url(nextcloud_host)
|
||||||
|
|
||||||
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
|
# Use NEXTCLOUD_PUBLIC_ISSUER_URL for IdP detection when set
|
||||||
|
# This handles the case where MCP server accesses Nextcloud via internal URL (http://app:80)
|
||||||
|
# but the issuer in OIDC discovery is the public URL (http://localhost:8080)
|
||||||
|
public_issuer_for_detection = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
|
if public_issuer_for_detection:
|
||||||
|
comparison_issuer = normalize_url(public_issuer_for_detection)
|
||||||
|
else:
|
||||||
|
comparison_issuer = nextcloud_normalized
|
||||||
|
|
||||||
|
is_external_idp = not issuer_normalized.startswith(comparison_issuer)
|
||||||
|
|
||||||
if is_external_idp:
|
if is_external_idp:
|
||||||
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
|
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
|
||||||
@@ -714,6 +777,28 @@ async def setup_oauth_config():
|
|||||||
oauth_provider = "nextcloud"
|
oauth_provider = "nextcloud"
|
||||||
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
|
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
|
||||||
|
|
||||||
|
# For integrated mode, rewrite OIDC endpoints to use internal URL
|
||||||
|
# The discovery document returns external URLs (http://localhost:8080)
|
||||||
|
# but the MCP server needs internal URLs (http://app:80) for backend requests
|
||||||
|
if jwks_uri and not os.getenv("OIDC_JWKS_URI"):
|
||||||
|
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
|
||||||
|
logger.info(
|
||||||
|
f" Auto-rewriting JWKS URI for internal access: {jwks_uri} → {internal_jwks_uri}"
|
||||||
|
)
|
||||||
|
jwks_uri = internal_jwks_uri
|
||||||
|
if introspection_uri and not os.getenv("OIDC_INTROSPECTION_URI"):
|
||||||
|
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
|
||||||
|
logger.info(
|
||||||
|
f" Auto-rewriting introspection URI for internal access: {introspection_uri} → {internal_introspection_uri}"
|
||||||
|
)
|
||||||
|
introspection_uri = internal_introspection_uri
|
||||||
|
if userinfo_uri:
|
||||||
|
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
|
||||||
|
logger.info(
|
||||||
|
f" Auto-rewriting userinfo URI for internal access: {userinfo_uri} → {internal_userinfo_uri}"
|
||||||
|
)
|
||||||
|
userinfo_uri = internal_userinfo_uri
|
||||||
|
|
||||||
# Check if offline access (refresh tokens) is enabled
|
# Check if offline access (refresh tokens) is enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
"true",
|
"true",
|
||||||
@@ -931,6 +1016,33 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Initialize observability (logging will be configured by uvicorn)
|
# Initialize observability (logging will be configured by uvicorn)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Validate configuration and detect deployment mode
|
||||||
|
mode, config_errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
if config_errors:
|
||||||
|
error_msg = (
|
||||||
|
f"Configuration validation failed for {mode.value} mode:\n"
|
||||||
|
+ "\n".join(f" - {err}" for err in config_errors)
|
||||||
|
+ "\n\n"
|
||||||
|
+ get_mode_summary(mode)
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
logger.info(f"✅ Configuration validated successfully for {mode.value} mode")
|
||||||
|
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
|
||||||
|
|
||||||
|
# Derive helper variables for backward compatibility with existing code
|
||||||
|
oauth_enabled = mode in (
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
|
)
|
||||||
|
deployment_mode = (
|
||||||
|
DeploymentMode.SMITHERY_STATELESS
|
||||||
|
if mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
else DeploymentMode.SELF_HOSTED
|
||||||
|
)
|
||||||
|
|
||||||
# Setup Prometheus metrics (always enabled by default)
|
# Setup Prometheus metrics (always enabled by default)
|
||||||
if settings.metrics_enabled:
|
if settings.metrics_enabled:
|
||||||
setup_metrics(port=settings.metrics_port)
|
setup_metrics(port=settings.metrics_port)
|
||||||
@@ -954,11 +1066,77 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine authentication mode and deployment mode
|
# Initialize OAuth credentials for multi-user modes that need background operations
|
||||||
oauth_enabled = is_oauth_mode()
|
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
|
||||||
deployment_mode = get_deployment_mode()
|
# to avoid async context issues
|
||||||
|
multi_user_basic_oauth_creds: tuple[str, str] | None = None
|
||||||
|
|
||||||
if oauth_enabled:
|
if (
|
||||||
|
mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
and settings.vector_sync_enabled
|
||||||
|
and settings.enable_offline_access
|
||||||
|
):
|
||||||
|
print(
|
||||||
|
f"DEBUG: Multi-user BasicAuth mode detected, vector_sync={settings.vector_sync_enabled}, offline_access={settings.enable_offline_access}"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Multi-user BasicAuth with vector sync - checking for OAuth credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for static credentials first
|
||||||
|
static_client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID")
|
||||||
|
static_client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET")
|
||||||
|
|
||||||
|
if static_client_id and static_client_secret:
|
||||||
|
print("DEBUG: Using static OAuth credentials")
|
||||||
|
logger.info("Using static OAuth credentials for background operations")
|
||||||
|
multi_user_basic_oauth_creds = (static_client_id, static_client_secret)
|
||||||
|
else:
|
||||||
|
# Perform DCR before uvicorn starts (same lifecycle as OAuth modes)
|
||||||
|
print("DEBUG: No static credentials, attempting DCR...")
|
||||||
|
logger.info(
|
||||||
|
"OAuth credentials not configured - attempting Dynamic Client Registration..."
|
||||||
|
)
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
|
||||||
|
async def setup_multi_user_basic_dcr():
|
||||||
|
"""Setup DCR for multi-user BasicAuth background operations."""
|
||||||
|
# Construct registration endpoint directly from nextcloud_host
|
||||||
|
# Standard RFC 7591 endpoint pattern for Nextcloud OIDC
|
||||||
|
# This avoids relying on discovery doc which may use public URLs unreachable from containers
|
||||||
|
registration_endpoint = f"{settings.nextcloud_host}/apps/oidc/register"
|
||||||
|
logger.info(
|
||||||
|
f"Attempting Dynamic Client Registration at: {registration_endpoint}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Perform DCR
|
||||||
|
try:
|
||||||
|
# Assert nextcloud_host is not None (required for multi-user mode)
|
||||||
|
assert settings.nextcloud_host is not None, (
|
||||||
|
"NEXTCLOUD_HOST is required"
|
||||||
|
)
|
||||||
|
|
||||||
|
client_id, client_secret = await load_oauth_client_credentials(
|
||||||
|
nextcloud_host=settings.nextcloud_host,
|
||||||
|
registration_endpoint=registration_endpoint,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"✓ Dynamic Client Registration successful for background operations "
|
||||||
|
f"(client_id: {client_id[:16]}...)"
|
||||||
|
)
|
||||||
|
return (client_id, client_secret)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Dynamic Client Registration failed: {e}")
|
||||||
|
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
|
||||||
|
logger.warning("Background vector sync will be disabled.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Run DCR synchronously before uvicorn starts
|
||||||
|
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
|
||||||
|
|
||||||
|
# Create MCP server based on detected mode
|
||||||
|
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
# Asynchronously get the OAuth configuration
|
# Asynchronously get the OAuth configuration
|
||||||
import anyio
|
import anyio
|
||||||
@@ -1015,19 +1193,38 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
lifespan=oauth_lifespan,
|
lifespan=oauth_lifespan,
|
||||||
token_verifier=token_verifier,
|
token_verifier=token_verifier,
|
||||||
auth=auth_settings,
|
auth=auth_settings,
|
||||||
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif mode == AuthMode.SMITHERY_STATELESS:
|
||||||
|
logger.info("Configuring MCP server for Smithery stateless mode")
|
||||||
|
# json_response=True returns plain JSON-RPC instead of SSE format,
|
||||||
|
# required for Smithery scanner compatibility
|
||||||
|
mcp = FastMCP(
|
||||||
|
"Nextcloud MCP",
|
||||||
|
lifespan=app_lifespan_smithery,
|
||||||
|
json_response=True,
|
||||||
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
# BasicAuth modes (single-user or multi-user)
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
logger.info(f"Configuring MCP server for {mode.value} mode")
|
||||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
mcp = FastMCP(
|
||||||
# json_response=True returns plain JSON-RPC instead of SSE format,
|
"Nextcloud MCP",
|
||||||
# required for Smithery scanner compatibility
|
lifespan=app_lifespan_basic,
|
||||||
mcp = FastMCP(
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
)
|
transport_security=TransportSecuritySettings(
|
||||||
else:
|
enable_dns_rebinding_protection=False
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
),
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
)
|
||||||
|
|
||||||
@mcp.resource("nc://capabilities")
|
@mcp.resource("nc://capabilities")
|
||||||
async def nc_get_capabilities():
|
async def nc_get_capabilities():
|
||||||
@@ -1046,6 +1243,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"contacts": configure_contacts_tools,
|
"contacts": configure_contacts_tools,
|
||||||
"cookbook": configure_cookbook_tools,
|
"cookbook": configure_cookbook_tools,
|
||||||
"deck": configure_deck_tools,
|
"deck": configure_deck_tools,
|
||||||
|
"news": configure_news_tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If no specific apps are specified, enable all
|
# If no specific apps are specified, enable all
|
||||||
@@ -1064,8 +1262,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
# Register semantic search tools (cross-app feature)
|
# Register semantic search tools (cross-app feature)
|
||||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
||||||
settings = get_settings()
|
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||||
elif settings.vector_sync_enabled:
|
elif settings.vector_sync_enabled:
|
||||||
@@ -1152,13 +1348,20 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Set OAuth context for OAuth login routes (ADR-004)
|
# Set OAuth context for OAuth login routes (ADR-004)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
# Prepare OAuth config from setup_oauth_config closure variables
|
# Prepare OAuth config from setup_oauth_config closure variables
|
||||||
|
# Get nextcloud_host from settings (it was validated as required)
|
||||||
|
nextcloud_host_for_context = settings.nextcloud_host
|
||||||
|
if not nextcloud_host_for_context:
|
||||||
|
raise ValueError("NEXTCLOUD_HOST is required for OAuth mode")
|
||||||
|
|
||||||
mcp_server_url = os.getenv(
|
mcp_server_url = os.getenv(
|
||||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||||
)
|
)
|
||||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
nextcloud_resource_uri = os.getenv(
|
||||||
|
"NEXTCLOUD_RESOURCE_URI", nextcloud_host_for_context
|
||||||
|
)
|
||||||
discovery_url = os.getenv(
|
discovery_url = os.getenv(
|
||||||
"OIDC_DISCOVERY_URL",
|
"OIDC_DISCOVERY_URL",
|
||||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||||
|
|
||||||
@@ -1172,7 +1375,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||||
"scopes": scopes,
|
"scopes": scopes,
|
||||||
"nextcloud_host": nextcloud_host,
|
"nextcloud_host": nextcloud_host_for_context,
|
||||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||||
"oauth_provider": oauth_provider,
|
"oauth_provider": oauth_provider,
|
||||||
},
|
},
|
||||||
@@ -1184,7 +1387,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# We need to find it in the mounted routes
|
# We need to find it in the mounted routes
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/app":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
route.app.state.oauth_context = oauth_context_dict
|
browser_app = cast(Starlette, route.app)
|
||||||
|
browser_app.state.oauth_context = oauth_context_dict
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth context shared with browser_app for session auth"
|
"OAuth context shared with browser_app for session auth"
|
||||||
)
|
)
|
||||||
@@ -1194,29 +1398,87 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# BasicAuth mode - share storage with browser_app for webhook management
|
# BasicAuth mode - initialize storage for webhook management
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
basic_auth_storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await basic_auth_storage.initialize()
|
||||||
|
logger.info("Initialized refresh token storage for webhook management")
|
||||||
|
|
||||||
app.state.storage = storage
|
app.state.storage = basic_auth_storage
|
||||||
|
|
||||||
|
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
|
||||||
|
# This allows Astrolabe to use management APIs with OAuth bearer tokens
|
||||||
|
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
|
||||||
|
# Check if we have OAuth credentials from DCR
|
||||||
|
if multi_user_basic_oauth_creds:
|
||||||
|
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
|
||||||
|
|
||||||
|
# Create minimal oauth_context for management API authentication
|
||||||
|
nextcloud_host_for_context = settings.nextcloud_host
|
||||||
|
mcp_server_url = os.getenv(
|
||||||
|
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||||
|
)
|
||||||
|
discovery_url = os.getenv(
|
||||||
|
"OIDC_DISCOVERY_URL",
|
||||||
|
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
|
||||||
|
)
|
||||||
|
|
||||||
|
oauth_context_dict = {
|
||||||
|
"storage": basic_auth_storage,
|
||||||
|
"oauth_client": None, # Not needed for management APIs
|
||||||
|
"token_verifier": None, # Will be set when token broker is created
|
||||||
|
"config": {
|
||||||
|
"mcp_server_url": mcp_server_url,
|
||||||
|
"discovery_url": discovery_url,
|
||||||
|
"client_id": sync_client_id,
|
||||||
|
"client_secret": sync_client_secret,
|
||||||
|
"scopes": "", # Background sync only
|
||||||
|
"nextcloud_host": nextcloud_host_for_context,
|
||||||
|
"nextcloud_resource_uri": nextcloud_host_for_context,
|
||||||
|
"oauth_provider": "nextcloud", # Always Nextcloud for multi-user BasicAuth
|
||||||
|
},
|
||||||
|
}
|
||||||
|
app.state.oauth_context = oauth_context_dict
|
||||||
|
logger.info(
|
||||||
|
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
|
||||||
|
)
|
||||||
|
|
||||||
# Also share with browser_app for webhook routes
|
# Also share with browser_app for webhook routes
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/app":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
route.app.state.storage = storage
|
browser_app = cast(Starlette, route.app)
|
||||||
|
browser_app.state.storage = basic_auth_storage
|
||||||
|
if (
|
||||||
|
settings.enable_multi_user_basic_auth
|
||||||
|
and settings.enable_offline_access
|
||||||
|
and hasattr(app.state, "oauth_context")
|
||||||
|
):
|
||||||
|
browser_app.state.oauth_context = app.state.oauth_context
|
||||||
|
logger.info(
|
||||||
|
"OAuth context shared with browser_app for management APIs"
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Storage shared with browser_app for webhook management"
|
"Storage shared with browser_app for webhook management"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
# Start background vector sync tasks for BasicAuth mode (ADR-007)
|
# Start background vector sync tasks (ADR-007)
|
||||||
# Scanner runs at server-level (once), not per-session
|
# Scanner runs at server-level (once), not per-session
|
||||||
import anyio as anyio_module
|
import anyio as anyio_module
|
||||||
|
|
||||||
settings = get_settings()
|
# Re-use settings from outer scope (already validated)
|
||||||
if not oauth_enabled and settings.vector_sync_enabled:
|
# Note: enable_offline_access_for_sync, encryption_key, and refresh_token_storage
|
||||||
|
# are already defined in outer scope before mode split
|
||||||
|
|
||||||
|
# Multi-user BasicAuth uses OAuth-style background sync (with app passwords)
|
||||||
|
# So skip single-user BasicAuth vector sync if in multi-user mode
|
||||||
|
if (
|
||||||
|
settings.vector_sync_enabled
|
||||||
|
and not oauth_enabled
|
||||||
|
and not settings.enable_multi_user_basic_auth
|
||||||
|
):
|
||||||
|
# BasicAuth mode - single user sync
|
||||||
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
||||||
|
|
||||||
# Get username from environment
|
# Get username from environment
|
||||||
@@ -1265,10 +1527,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Also share with browser_app for /app route
|
# Also share with browser_app for /app route
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/app":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
route.app.state.document_send_stream = send_stream
|
browser_app = cast(Starlette, route.app)
|
||||||
route.app.state.document_receive_stream = receive_stream
|
browser_app.state.document_send_stream = send_stream
|
||||||
route.app.state.shutdown_event = shutdown_event
|
browser_app.state.document_receive_stream = receive_stream
|
||||||
route.app.state.scanner_wake_event = scanner_wake_event
|
browser_app.state.shutdown_event = shutdown_event
|
||||||
|
browser_app.state.scanner_wake_event = scanner_wake_event
|
||||||
logger.info("Vector sync state shared with browser_app for /app")
|
logger.info("Vector sync state shared with browser_app for /app")
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1311,8 +1574,207 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
shutdown_event.set()
|
shutdown_event.set()
|
||||||
await client.close()
|
await client.close()
|
||||||
# TaskGroup automatically cancels all tasks on exit
|
# TaskGroup automatically cancels all tasks on exit
|
||||||
|
|
||||||
|
elif (
|
||||||
|
settings.vector_sync_enabled
|
||||||
|
and (oauth_enabled or settings.enable_multi_user_basic_auth)
|
||||||
|
and settings.enable_offline_access
|
||||||
|
):
|
||||||
|
# OAuth mode with offline access - multi-user sync
|
||||||
|
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords)
|
||||||
|
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
||||||
|
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
|
oauth_processor_task,
|
||||||
|
user_manager_task,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get nextcloud_host (from settings - already validated)
|
||||||
|
nextcloud_host_for_sync = settings.nextcloud_host
|
||||||
|
if not nextcloud_host_for_sync:
|
||||||
|
raise ValueError("NEXTCLOUD_HOST required for vector sync")
|
||||||
|
|
||||||
|
# Get OIDC discovery URL (same as used for OAuth setup)
|
||||||
|
discovery_url = os.getenv(
|
||||||
|
"OIDC_DISCOVERY_URL",
|
||||||
|
f"{nextcloud_host_for_sync}/.well-known/openid-configuration",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get client credentials - these were obtained before uvicorn started
|
||||||
|
# For OAuth modes: from setup_oauth_config()
|
||||||
|
# For multi-user BasicAuth: from setup_multi_user_basic_dcr()
|
||||||
|
oauth_ctx = getattr(app.state, "oauth_context", {})
|
||||||
|
oauth_config = oauth_ctx.get("config", {})
|
||||||
|
sync_client_id = oauth_config.get("client_id")
|
||||||
|
sync_client_secret = oauth_config.get("client_secret")
|
||||||
|
|
||||||
|
# For multi-user BasicAuth mode, use pre-obtained credentials from outer scope
|
||||||
|
if not sync_client_id or not sync_client_secret:
|
||||||
|
if multi_user_basic_oauth_creds:
|
||||||
|
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
|
||||||
|
logger.info(
|
||||||
|
"Using pre-obtained OAuth credentials for background sync"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# No credentials available - DCR was attempted before uvicorn started but failed
|
||||||
|
sync_client_id = None
|
||||||
|
sync_client_secret = None
|
||||||
|
logger.warning(
|
||||||
|
"OAuth credentials not available for background sync "
|
||||||
|
"(DCR was attempted during startup but failed)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only start vector sync if credentials are available
|
||||||
|
if sync_client_id and sync_client_secret:
|
||||||
|
# Get storage - different for OAuth vs multi-user BasicAuth modes
|
||||||
|
# OAuth mode: refresh_token_storage (from setup_oauth_config)
|
||||||
|
# Multi-user BasicAuth: app.state.storage (basic_auth_storage)
|
||||||
|
token_storage = (
|
||||||
|
refresh_token_storage if oauth_enabled else app.state.storage
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create token broker for background operations
|
||||||
|
# Note: storage handles encryption internally, no key needed here
|
||||||
|
# Client credentials are needed for token refresh operations
|
||||||
|
token_broker = TokenBrokerService(
|
||||||
|
storage=token_storage,
|
||||||
|
oidc_discovery_url=discovery_url,
|
||||||
|
nextcloud_host=nextcloud_host_for_sync,
|
||||||
|
client_id=sync_client_id,
|
||||||
|
client_secret=sync_client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store token broker in oauth_context for management API (revoke endpoint)
|
||||||
|
if hasattr(app.state, "oauth_context"):
|
||||||
|
app.state.oauth_context["token_broker"] = token_broker
|
||||||
|
logger.info(
|
||||||
|
"Token broker added to oauth_context for management API"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Initialize Qdrant collection before starting background tasks
|
||||||
|
logger.info("Initializing Qdrant collection...")
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
|
try:
|
||||||
|
await get_qdrant_client() # Triggers collection creation if needed
|
||||||
|
logger.info("Qdrant collection ready")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize Qdrant collection: {e}")
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Initialize shared state
|
||||||
|
send_stream, receive_stream = anyio_module.create_memory_object_stream(
|
||||||
|
max_buffer_size=settings.vector_sync_queue_max_size
|
||||||
|
)
|
||||||
|
shutdown_event = anyio_module.Event()
|
||||||
|
scanner_wake_event = anyio_module.Event()
|
||||||
|
|
||||||
|
# User state tracking for user manager
|
||||||
|
user_states: dict = {}
|
||||||
|
|
||||||
|
# Store in app state for access from routes (ADR-007)
|
||||||
|
app.state.document_send_stream = send_stream
|
||||||
|
app.state.document_receive_stream = receive_stream
|
||||||
|
app.state.shutdown_event = shutdown_event
|
||||||
|
app.state.scanner_wake_event = scanner_wake_event
|
||||||
|
|
||||||
|
# Also store in module singleton for FastMCP session lifespans
|
||||||
|
_vector_sync_state.document_send_stream = send_stream
|
||||||
|
_vector_sync_state.document_receive_stream = receive_stream
|
||||||
|
_vector_sync_state.shutdown_event = shutdown_event
|
||||||
|
_vector_sync_state.scanner_wake_event = scanner_wake_event
|
||||||
|
logger.info("Vector sync state stored in module singleton")
|
||||||
|
|
||||||
|
# Also share with browser_app for /app route
|
||||||
|
for route in app.routes:
|
||||||
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
|
browser_app = cast(Starlette, route.app)
|
||||||
|
browser_app.state.document_send_stream = send_stream
|
||||||
|
browser_app.state.document_receive_stream = receive_stream
|
||||||
|
browser_app.state.shutdown_event = shutdown_event
|
||||||
|
browser_app.state.scanner_wake_event = scanner_wake_event
|
||||||
|
logger.info(
|
||||||
|
"Vector sync state shared with browser_app for /app"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Start background tasks using anyio TaskGroup
|
||||||
|
async with anyio_module.create_task_group() as tg:
|
||||||
|
# Start user manager task (supervises per-user scanners)
|
||||||
|
await tg.start(
|
||||||
|
user_manager_task,
|
||||||
|
send_stream,
|
||||||
|
shutdown_event,
|
||||||
|
scanner_wake_event,
|
||||||
|
token_broker,
|
||||||
|
token_storage, # Use token_storage (works for both OAuth and multi-user BasicAuth)
|
||||||
|
nextcloud_host_for_sync,
|
||||||
|
user_states,
|
||||||
|
tg,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start processor pool (each gets a cloned receive stream)
|
||||||
|
for i in range(settings.vector_sync_processor_workers):
|
||||||
|
await tg.start(
|
||||||
|
oauth_processor_task,
|
||||||
|
i,
|
||||||
|
receive_stream.clone(),
|
||||||
|
shutdown_event,
|
||||||
|
token_broker,
|
||||||
|
nextcloud_host_for_sync,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Background sync tasks started: 1 user manager + "
|
||||||
|
f"{settings.vector_sync_processor_workers} processors"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Run MCP session manager and yield
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
await stack.enter_async_context(mcp.session_manager.run())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
# Shutdown signal
|
||||||
|
logger.info("Shutting down background sync tasks")
|
||||||
|
shutdown_event.set()
|
||||||
|
# Close token broker HTTP client
|
||||||
|
if token_broker._http_client:
|
||||||
|
await token_broker._http_client.aclose()
|
||||||
|
# TaskGroup automatically cancels all tasks on exit
|
||||||
|
else:
|
||||||
|
# No OAuth credentials available for background sync
|
||||||
|
logger.warning(
|
||||||
|
"Skipping background vector sync - OAuth credentials not available. "
|
||||||
|
"Multi-user BasicAuth mode will run without semantic search background operations. "
|
||||||
|
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
|
||||||
|
)
|
||||||
|
# Just run MCP session manager without vector sync
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
await stack.enter_async_context(mcp.session_manager.run())
|
||||||
|
yield
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# No vector sync - just run MCP session manager
|
# No vector sync - just run MCP session manager
|
||||||
|
if settings.vector_sync_enabled:
|
||||||
|
# Log why vector sync is not starting
|
||||||
|
if oauth_enabled and not settings.enable_offline_access:
|
||||||
|
logger.warning(
|
||||||
|
"Vector sync enabled but ENABLE_OFFLINE_ACCESS=false - "
|
||||||
|
"vector sync requires offline access in OAuth mode"
|
||||||
|
)
|
||||||
|
elif oauth_enabled and not refresh_token_storage:
|
||||||
|
logger.warning(
|
||||||
|
"Vector sync enabled but refresh token storage not available"
|
||||||
|
)
|
||||||
|
elif oauth_enabled and not os.getenv("TOKEN_ENCRYPTION_KEY"):
|
||||||
|
logger.warning(
|
||||||
|
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
|
||||||
|
)
|
||||||
async with AsyncExitStack() as stack:
|
async with AsyncExitStack() as stack:
|
||||||
await stack.enter_async_context(mcp.session_manager.run())
|
await stack.enter_async_context(mcp.session_manager.run())
|
||||||
yield
|
yield
|
||||||
@@ -1372,12 +1834,20 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
is_ready = False
|
is_ready = False
|
||||||
|
|
||||||
# Check authentication configuration
|
# Check authentication configuration
|
||||||
if oauth_enabled:
|
# Report the deployment mode, not just whether OAuth is enabled
|
||||||
# OAuth mode - just verify we got this far (token_verifier initialized in lifespan)
|
# This helps clients (like Astrolabe) determine which auth flow to use
|
||||||
|
if (
|
||||||
|
mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
or mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
):
|
||||||
checks["auth_mode"] = "oauth"
|
checks["auth_mode"] = "oauth"
|
||||||
checks["auth_configured"] = "ok"
|
checks["auth_configured"] = "ok"
|
||||||
else:
|
elif mode == AuthMode.MULTI_USER_BASIC:
|
||||||
# BasicAuth mode - verify credentials are set
|
checks["auth_mode"] = "multi_user_basic"
|
||||||
|
checks["auth_configured"] = "ok"
|
||||||
|
# Indicate if app passwords are supported (when offline_access enabled)
|
||||||
|
checks["supports_app_passwords"] = settings.enable_offline_access
|
||||||
|
elif mode == AuthMode.SINGLE_USER_BASIC:
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
username = os.getenv("NEXTCLOUD_USERNAME")
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
password = os.getenv("NEXTCLOUD_PASSWORD")
|
||||||
if username and password:
|
if username and password:
|
||||||
@@ -1387,6 +1857,9 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
checks["auth_mode"] = "basic"
|
checks["auth_mode"] = "basic"
|
||||||
checks["auth_configured"] = "error: credentials not set"
|
checks["auth_configured"] = "error: credentials not set"
|
||||||
is_ready = False
|
is_ready = False
|
||||||
|
elif mode == AuthMode.SMITHERY_STATELESS:
|
||||||
|
checks["auth_mode"] = "smithery"
|
||||||
|
checks["auth_configured"] = "ok"
|
||||||
|
|
||||||
# Check Qdrant status if using network mode (external Qdrant service)
|
# Check Qdrant status if using network mode (external Qdrant service)
|
||||||
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
# In-memory and persistent modes use embedded Qdrant, no external service to check
|
||||||
@@ -1468,6 +1941,70 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
|
||||||
|
|
||||||
|
# Add management API endpoints for Nextcloud PHP app
|
||||||
|
# Available in: OAuth modes OR multi-user BasicAuth with offline access (for Astrolabe integration)
|
||||||
|
enable_management_apis = oauth_enabled or (
|
||||||
|
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||||
|
)
|
||||||
|
if enable_management_apis:
|
||||||
|
from nextcloud_mcp_server.api.management import (
|
||||||
|
create_webhook,
|
||||||
|
delete_webhook,
|
||||||
|
get_chunk_context,
|
||||||
|
get_installed_apps,
|
||||||
|
get_server_status,
|
||||||
|
get_user_session,
|
||||||
|
get_vector_sync_status,
|
||||||
|
list_webhooks,
|
||||||
|
revoke_user_access,
|
||||||
|
unified_search,
|
||||||
|
vector_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/vector-sync/status",
|
||||||
|
get_vector_sync_status,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/session",
|
||||||
|
get_user_session,
|
||||||
|
methods=["GET"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/api/v1/users/{user_id}/revoke",
|
||||||
|
revoke_user_access,
|
||||||
|
methods=["POST"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
|
||||||
|
)
|
||||||
|
routes.append(
|
||||||
|
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
|
||||||
|
)
|
||||||
|
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
|
||||||
|
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
|
||||||
|
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
|
||||||
|
# Webhook management endpoints
|
||||||
|
routes.append(Route("/api/v1/webhooks", list_webhooks, methods=["GET"]))
|
||||||
|
routes.append(Route("/api/v1/webhooks", create_webhook, methods=["POST"]))
|
||||||
|
routes.append(
|
||||||
|
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
|
||||||
|
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
|
||||||
|
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
|
||||||
|
"/api/v1/webhooks"
|
||||||
|
)
|
||||||
|
|
||||||
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
|
|
||||||
@@ -1836,4 +2373,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
app = SmitheryConfigMiddleware(app)
|
app = SmitheryConfigMiddleware(app)
|
||||||
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
||||||
|
|
||||||
|
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
app = BasicAuthMiddleware(app)
|
||||||
|
logger.info(
|
||||||
|
"BasicAuthMiddleware enabled - multi-user BasicAuth pass-through mode active"
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Client for querying Astrolabe Management API for background sync credentials.
|
||||||
|
|
||||||
|
This client uses OAuth client credentials flow to authenticate to Nextcloud
|
||||||
|
and retrieve user app passwords for background sync operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AstrolabeClient:
|
||||||
|
"""Client for querying Astrolabe API for background sync credentials.
|
||||||
|
|
||||||
|
Uses OAuth client credentials flow to authenticate as the MCP server
|
||||||
|
and retrieve user app passwords that are stored in Nextcloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nextcloud_host: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Astrolabe client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Nextcloud base URL (e.g., https://cloud.example.com)
|
||||||
|
client_id: OAuth client ID for MCP server
|
||||||
|
client_secret: OAuth client secret
|
||||||
|
"""
|
||||||
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self._token_cache: Optional[dict] = None # {access_token, expires_at}
|
||||||
|
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
"""
|
||||||
|
Get access token using OAuth client credentials flow.
|
||||||
|
|
||||||
|
Tokens are cached with 1-minute early refresh to avoid expiration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token request fails
|
||||||
|
"""
|
||||||
|
# Check cache
|
||||||
|
if self._token_cache and time.time() < self._token_cache["expires_at"]:
|
||||||
|
logger.debug("Using cached OAuth token for Astrolabe API")
|
||||||
|
return self._token_cache["access_token"]
|
||||||
|
|
||||||
|
# Discover token endpoint
|
||||||
|
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||||
|
discovery_resp = await client.get(discovery_url)
|
||||||
|
discovery_resp.raise_for_status()
|
||||||
|
token_endpoint = discovery_resp.json()["token_endpoint"]
|
||||||
|
|
||||||
|
logger.debug(f"Requesting client credentials token from {token_endpoint}")
|
||||||
|
|
||||||
|
# Request token using client credentials grant
|
||||||
|
token_resp = await client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"scope": "openid", # Minimal scope
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token_resp.raise_for_status()
|
||||||
|
data = token_resp.json()
|
||||||
|
|
||||||
|
# Cache with 1-minute early refresh
|
||||||
|
expires_in = data.get("expires_in", 3600)
|
||||||
|
self._token_cache = {
|
||||||
|
"access_token": data["access_token"],
|
||||||
|
"expires_at": time.time() + expires_in - 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
async def get_user_app_password(self, user_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve user's app password for background sync.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
App password string, or None if user hasn't provisioned
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If API request fails (except 404)
|
||||||
|
"""
|
||||||
|
token = await self.get_access_token()
|
||||||
|
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug(f"No app password configured for user: {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
|
||||||
|
)
|
||||||
|
return data.get("app_password")
|
||||||
|
|
||||||
|
async def get_background_sync_status(self, user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get background sync status for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: has_access, credential_type, provisioned_at
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If API request fails
|
||||||
|
"""
|
||||||
|
# For now, check if app password exists
|
||||||
|
# In the future, this could query a dedicated status endpoint
|
||||||
|
app_password = await self.get_user_app_password(user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_access": app_password is not None,
|
||||||
|
"credential_type": "app_password" if app_password else None,
|
||||||
|
"provisioned_at": None, # TODO: Get from API if available
|
||||||
|
}
|
||||||
@@ -24,6 +24,26 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _should_use_secure_cookies() -> bool:
|
||||||
|
"""Determine if cookies should have secure flag.
|
||||||
|
|
||||||
|
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if cookies should be secure (HTTPS), False otherwise
|
||||||
|
"""
|
||||||
|
# Explicit configuration takes precedence
|
||||||
|
explicit = os.getenv("COOKIE_SECURE", "").lower()
|
||||||
|
if explicit == "true":
|
||||||
|
return True
|
||||||
|
if explicit == "false":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Auto-detect from NEXTCLOUD_HOST protocol
|
||||||
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
|
||||||
|
return nextcloud_host.startswith("https://")
|
||||||
|
|
||||||
|
|
||||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||||
|
|
||||||
@@ -50,6 +70,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
||||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
||||||
|
|
||||||
|
# Get redirect URL from query params (default to /app)
|
||||||
|
next_url = request.query_params.get("next", "/app")
|
||||||
|
logger.info(f"oauth_login - next_url: {next_url}")
|
||||||
|
|
||||||
# Generate state for CSRF protection
|
# Generate state for CSRF protection
|
||||||
state = secrets.token_urlsafe(32)
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
@@ -71,7 +95,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
await storage.store_oauth_session(
|
await storage.store_oauth_session(
|
||||||
session_id=state, # Use state as session ID
|
session_id=state, # Use state as session ID
|
||||||
client_id="browser-ui",
|
client_id="browser-ui",
|
||||||
client_redirect_uri="/app",
|
client_redirect_uri=next_url, # Store the redirect URL for after auth
|
||||||
state=state,
|
state=state,
|
||||||
code_challenge=code_challenge,
|
code_challenge=code_challenge,
|
||||||
code_challenge_method="S256",
|
code_challenge_method="S256",
|
||||||
@@ -85,6 +109,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
if not oauth_client.authorization_endpoint:
|
if not oauth_client.authorization_endpoint:
|
||||||
await oauth_client.discover()
|
await oauth_client.discover()
|
||||||
|
|
||||||
|
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
|
||||||
|
nextcloud_resource_uri = oauth_config.get(
|
||||||
|
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
|
||||||
|
)
|
||||||
|
|
||||||
idp_params = {
|
idp_params = {
|
||||||
"client_id": oauth_client.client_id,
|
"client_id": oauth_client.client_id,
|
||||||
"redirect_uri": callback_uri,
|
"redirect_uri": callback_uri,
|
||||||
@@ -94,6 +123,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
"code_challenge": code_challenge,
|
"code_challenge": code_challenge,
|
||||||
"code_challenge_method": "S256",
|
"code_challenge_method": "S256",
|
||||||
"prompt": "consent", # Ensure refresh token
|
"prompt": "consent", # Ensure refresh token
|
||||||
|
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
||||||
}
|
}
|
||||||
|
|
||||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
||||||
@@ -131,6 +161,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
|
||||||
|
nextcloud_resource_uri = oauth_config.get(
|
||||||
|
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
|
||||||
|
)
|
||||||
|
|
||||||
idp_params = {
|
idp_params = {
|
||||||
"client_id": oauth_config["client_id"],
|
"client_id": oauth_config["client_id"],
|
||||||
"redirect_uri": callback_uri,
|
"redirect_uri": callback_uri,
|
||||||
@@ -140,6 +175,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
"code_challenge": code_challenge,
|
"code_challenge": code_challenge,
|
||||||
"code_challenge_method": "S256",
|
"code_challenge_method": "S256",
|
||||||
"prompt": "consent", # Ensure refresh token
|
"prompt": "consent", # Ensure refresh token
|
||||||
|
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
||||||
}
|
}
|
||||||
|
|
||||||
# Debug: Log full parameters
|
# Debug: Log full parameters
|
||||||
@@ -214,12 +250,15 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
oauth_client = oauth_ctx["oauth_client"]
|
oauth_client = oauth_ctx["oauth_client"]
|
||||||
oauth_config = oauth_ctx["config"]
|
oauth_config = oauth_ctx["config"]
|
||||||
|
|
||||||
# Retrieve code_verifier from session storage (PKCE required for all modes)
|
# Retrieve code_verifier and redirect URL from session storage
|
||||||
code_verifier = ""
|
code_verifier = ""
|
||||||
|
next_url = "/app" # Default redirect
|
||||||
oauth_session = await storage.get_oauth_session(state)
|
oauth_session = await storage.get_oauth_session(state)
|
||||||
if oauth_session:
|
if oauth_session:
|
||||||
# code_verifier was stored in mcp_authorization_code field
|
# code_verifier was stored in mcp_authorization_code field
|
||||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||||
|
# next_url was stored in client_redirect_uri field
|
||||||
|
next_url = oauth_session.get("client_redirect_uri", "/app")
|
||||||
# Clean up the temporary session
|
# Clean up the temporary session
|
||||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||||
|
|
||||||
@@ -262,6 +301,25 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
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,
|
||||||
@@ -338,16 +396,35 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
user_id = f"user-{secrets.token_hex(8)}"
|
user_id = f"user-{secrets.token_hex(8)}"
|
||||||
username = "unknown"
|
username = "unknown"
|
||||||
|
|
||||||
|
# Calculate refresh token expiration from token response
|
||||||
|
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||||
|
refresh_expires_at = None
|
||||||
|
if refresh_expires_in:
|
||||||
|
import time
|
||||||
|
|
||||||
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
|
logger.info(
|
||||||
|
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract granted scopes
|
||||||
|
granted_scopes = (
|
||||||
|
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||||
|
)
|
||||||
|
|
||||||
# Store refresh token (for background jobs ONLY)
|
# Store refresh token (for background jobs ONLY)
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||||
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
||||||
|
logger.info(f" Granted scopes: {granted_scopes}")
|
||||||
|
logger.info(f" Expires at: {refresh_expires_at}")
|
||||||
await storage.store_refresh_token(
|
await storage.store_refresh_token(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
refresh_token=refresh_token,
|
refresh_token=refresh_token,
|
||||||
expires_at=None,
|
expires_at=refresh_expires_at,
|
||||||
flow_type="browser", # Browser-based login flow
|
flow_type="browser", # Browser-based login flow
|
||||||
provisioning_client_id=state, # Store state for unified session lookup
|
provisioning_client_id=state, # Store state for unified session lookup
|
||||||
|
scopes=granted_scopes,
|
||||||
)
|
)
|
||||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -383,13 +460,14 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
# Continue anyway - profile cache is optional for browser UI
|
# Continue anyway - profile cache is optional for browser UI
|
||||||
|
|
||||||
# Create response and set session cookie
|
# Create response and set session cookie
|
||||||
response = RedirectResponse("/app", status_code=302)
|
# Redirect to stored next_url (from OAuth session) or /app as default
|
||||||
|
response = RedirectResponse(next_url, status_code=302)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="mcp_session",
|
key="mcp_session",
|
||||||
value=user_id,
|
value=user_id,
|
||||||
max_age=86400 * 30, # 30 days
|
max_age=86400 * 30, # 30 days
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=False, # Set to True in production with HTTPS
|
secure=_should_use_secure_cookies(),
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -517,12 +517,23 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Calculate refresh token expiration from token response
|
||||||
|
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||||
|
refresh_expires_at = None
|
||||||
|
if refresh_expires_in:
|
||||||
|
import time
|
||||||
|
|
||||||
|
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||||
|
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
||||||
|
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
||||||
|
|
||||||
logger.info("Storing refresh token:")
|
logger.info("Storing refresh token:")
|
||||||
logger.info(f" user_id: {user_id}")
|
logger.info(f" user_id: {user_id}")
|
||||||
logger.info(" flow_type: flow2")
|
logger.info(" flow_type: flow2")
|
||||||
logger.info(" token_audience: nextcloud")
|
logger.info(" token_audience: nextcloud")
|
||||||
logger.info(f" provisioning_client_id: {state[:16]}...")
|
logger.info(f" provisioning_client_id: {state[:16]}...")
|
||||||
logger.info(f" scopes: {granted_scopes}")
|
logger.info(f" scopes: {granted_scopes}")
|
||||||
|
logger.info(f" expires_at: {refresh_expires_at}")
|
||||||
|
|
||||||
await storage.store_refresh_token(
|
await storage.store_refresh_token(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -531,7 +542,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
token_audience="nextcloud",
|
token_audience="nextcloud",
|
||||||
provisioning_client_id=state, # Store which client initiated provisioning
|
provisioning_client_id=state, # Store which client initiated provisioning
|
||||||
scopes=granted_scopes,
|
scopes=granted_scopes,
|
||||||
expires_at=None, # Refresh tokens typically don't expire
|
expires_at=refresh_expires_at,
|
||||||
)
|
)
|
||||||
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
|
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
|
|||||||
@@ -201,8 +201,15 @@ function vizApp() {
|
|||||||
return `${baseUrl}/apps/calendar`;
|
return `${baseUrl}/apps/calendar`;
|
||||||
case 'contact':
|
case 'contact':
|
||||||
return `${baseUrl}/apps/contacts`;
|
return `${baseUrl}/apps/contacts`;
|
||||||
case 'deck':
|
case 'deck_card':
|
||||||
|
// URL pattern: /apps/deck/board/:boardId/card/:cardId
|
||||||
|
if (result.metadata && result.metadata.board_id) {
|
||||||
|
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
|
||||||
|
}
|
||||||
|
// Fallback if board_id not available
|
||||||
return `${baseUrl}/apps/deck`;
|
return `${baseUrl}/apps/deck`;
|
||||||
|
case 'news_item':
|
||||||
|
return `${baseUrl}/apps/news/item/${result.id}`;
|
||||||
default:
|
default:
|
||||||
return `${baseUrl}`;
|
return `${baseUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,14 @@ class RefreshTokenStorage:
|
|||||||
return cls(db_path=db_path, encryption_key=encryption_key)
|
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Initialize database schema"""
|
"""
|
||||||
|
Initialize database schema using Alembic migrations.
|
||||||
|
|
||||||
|
This method handles three scenarios:
|
||||||
|
1. New database: Run migrations from scratch
|
||||||
|
2. Pre-Alembic database: Stamp with initial revision (no changes)
|
||||||
|
3. Alembic-managed database: Upgrade to latest version
|
||||||
|
"""
|
||||||
if self._initialized:
|
if self._initialized:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -125,137 +132,59 @@ class RefreshTokenStorage:
|
|||||||
db_dir = Path(self.db_path).parent
|
db_dir = Path(self.db_path).parent
|
||||||
db_dir.mkdir(parents=True, exist_ok=True)
|
db_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Set restrictive permissions on database file
|
# Set restrictive permissions on database file if it exists
|
||||||
if Path(self.db_path).exists():
|
if Path(self.db_path).exists():
|
||||||
os.chmod(self.db_path, 0o600)
|
os.chmod(self.db_path, 0o600)
|
||||||
|
|
||||||
|
# Check database state and run appropriate migration strategy
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
await db.execute(
|
# Check if database is managed by Alembic
|
||||||
"""
|
cursor = await db.execute(
|
||||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
|
||||||
user_id TEXT PRIMARY KEY,
|
)
|
||||||
encrypted_token BLOB NOT NULL,
|
has_alembic = await cursor.fetchone() is not None
|
||||||
expires_at INTEGER,
|
|
||||||
created_at INTEGER NOT NULL,
|
if not has_alembic:
|
||||||
updated_at INTEGER NOT NULL,
|
# Check if this is a pre-Alembic database with existing schema
|
||||||
-- ADR-004 Progressive Consent fields
|
cursor = await db.execute(
|
||||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
|
||||||
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
|
|
||||||
provisioned_at INTEGER, -- When Flow 2 was completed
|
|
||||||
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
|
|
||||||
scopes TEXT, -- JSON array of granted scopes
|
|
||||||
-- Browser session profile cache
|
|
||||||
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
|
|
||||||
profile_cached_at INTEGER -- When profile was last cached
|
|
||||||
)
|
)
|
||||||
"""
|
has_schema = await cursor.fetchone() is not None
|
||||||
)
|
|
||||||
|
|
||||||
await db.execute(
|
if has_schema:
|
||||||
"""
|
logger.info(
|
||||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
f"Detected pre-Alembic database at {self.db_path}, "
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
"stamping with initial revision"
|
||||||
timestamp INTEGER NOT NULL,
|
)
|
||||||
event TEXT NOT NULL,
|
else:
|
||||||
user_id TEXT NOT NULL,
|
logger.info(
|
||||||
resource_type TEXT,
|
f"Initializing new database at {self.db_path} with migrations"
|
||||||
resource_id TEXT,
|
)
|
||||||
auth_method TEXT,
|
|
||||||
hostname TEXT
|
# Run migrations in a worker thread using anyio.to_thread
|
||||||
|
# This allows Alembic to run its own async operations in a separate context
|
||||||
|
from anyio import to_thread
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||||
|
|
||||||
|
if not has_alembic:
|
||||||
|
if has_schema:
|
||||||
|
# Stamp existing database without running migrations
|
||||||
|
await to_thread.run_sync(stamp_database, self.db_path, "001")
|
||||||
|
logger.info(
|
||||||
|
"Pre-Alembic database stamped successfully. "
|
||||||
|
"Future schema changes will use migrations."
|
||||||
)
|
)
|
||||||
"""
|
else:
|
||||||
)
|
# New database - run migrations
|
||||||
|
await to_thread.run_sync(upgrade_database, self.db_path, "head")
|
||||||
|
logger.info("Database initialized with migrations")
|
||||||
|
else:
|
||||||
|
# Alembic-managed database - upgrade to latest
|
||||||
|
await to_thread.run_sync(upgrade_database, self.db_path, "head")
|
||||||
|
logger.info("Database upgraded to latest version")
|
||||||
|
|
||||||
# Create index on audit logs for efficient queries
|
# Set restrictive permissions after initialization
|
||||||
await db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
|
|
||||||
"ON audit_logs(user_id, timestamp)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# OAuth client credentials storage
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
client_id TEXT UNIQUE NOT NULL,
|
|
||||||
encrypted_client_secret BLOB NOT NULL,
|
|
||||||
client_id_issued_at INTEGER NOT NULL,
|
|
||||||
client_secret_expires_at INTEGER NOT NULL,
|
|
||||||
redirect_uris TEXT NOT NULL,
|
|
||||||
encrypted_registration_access_token BLOB,
|
|
||||||
registration_client_uri TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
updated_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# OAuth flow sessions (ADR-004 Progressive Consent)
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
|
||||||
session_id TEXT PRIMARY KEY,
|
|
||||||
client_id TEXT,
|
|
||||||
client_redirect_uri TEXT NOT NULL,
|
|
||||||
state TEXT,
|
|
||||||
code_challenge TEXT,
|
|
||||||
code_challenge_method TEXT,
|
|
||||||
mcp_authorization_code TEXT UNIQUE,
|
|
||||||
idp_access_token TEXT,
|
|
||||||
idp_refresh_token TEXT,
|
|
||||||
user_id TEXT,
|
|
||||||
created_at INTEGER NOT NULL,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
-- ADR-004 Progressive Consent fields
|
|
||||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
|
||||||
requested_scopes TEXT, -- JSON array of requested scopes
|
|
||||||
granted_scopes TEXT, -- JSON array of granted scopes
|
|
||||||
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create index for MCP authorization code lookups
|
|
||||||
await db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
|
|
||||||
"ON oauth_sessions(mcp_authorization_code)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Schema version tracking
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_version (
|
|
||||||
version INTEGER PRIMARY KEY,
|
|
||||||
applied_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Registered webhooks tracking (both BasicAuth and OAuth modes)
|
|
||||||
await db.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS registered_webhooks (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
webhook_id INTEGER NOT NULL UNIQUE,
|
|
||||||
preset_id TEXT NOT NULL,
|
|
||||||
created_at REAL NOT NULL
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create indexes for efficient webhook queries
|
|
||||||
await db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
|
|
||||||
"ON registered_webhooks(preset_id)"
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
|
|
||||||
"ON registered_webhooks(created_at)"
|
|
||||||
)
|
|
||||||
|
|
||||||
await db.commit()
|
|
||||||
|
|
||||||
# Set restrictive permissions after creation
|
|
||||||
os.chmod(self.db_path, 0o600)
|
os.chmod(self.db_path, 0o600)
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
@@ -287,6 +216,8 @@ class RefreshTokenStorage:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|
||||||
|
# Type narrowing: cipher is set after initialize()
|
||||||
|
assert self.cipher is not None
|
||||||
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
scopes_json = json.dumps(scopes) if scopes else None
|
scopes_json = json.dumps(scopes) if scopes else None
|
||||||
@@ -432,6 +363,9 @@ class RefreshTokenStorage:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|
||||||
|
# Type narrowing: cipher is set after initialize()
|
||||||
|
assert self.cipher is not None
|
||||||
|
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
@@ -516,6 +450,9 @@ class RefreshTokenStorage:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|
||||||
|
# Type narrowing: cipher is set after initialize()
|
||||||
|
assert self.cipher is not None
|
||||||
|
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""
|
"""
|
||||||
@@ -687,6 +624,9 @@ class RefreshTokenStorage:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|
||||||
|
# Type narrowing: cipher is set after initialize()
|
||||||
|
assert self.cipher is not None
|
||||||
|
|
||||||
# Encrypt sensitive data
|
# Encrypt sensitive data
|
||||||
encrypted_secret = self.cipher.encrypt(client_secret.encode())
|
encrypted_secret = self.cipher.encrypt(client_secret.encode())
|
||||||
encrypted_reg_token = (
|
encrypted_reg_token = (
|
||||||
@@ -757,6 +697,9 @@ class RefreshTokenStorage:
|
|||||||
if not self._initialized:
|
if not self._initialized:
|
||||||
await self.initialize()
|
await self.initialize()
|
||||||
|
|
||||||
|
# Type narrowing: cipher is set after initialize()
|
||||||
|
assert self.cipher is not None
|
||||||
|
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
async with aiosqlite.connect(self.db_path) as db:
|
||||||
async with db.execute(
|
async with db.execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -65,8 +65,12 @@
|
|||||||
<span>Contacts</span>
|
<span>Contacts</span>
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||||
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
|
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
|
||||||
<span>Deck</span>
|
<span>Deck Cards</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||||
|
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
|
||||||
|
<span>News</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from typing import Dict, Optional, Tuple
|
|||||||
import anyio
|
import anyio
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
from cryptography.fernet import Fernet
|
|
||||||
|
|
||||||
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
|
||||||
@@ -104,7 +103,8 @@ class TokenBrokerService:
|
|||||||
storage: RefreshTokenStorage,
|
storage: RefreshTokenStorage,
|
||||||
oidc_discovery_url: str,
|
oidc_discovery_url: str,
|
||||||
nextcloud_host: str,
|
nextcloud_host: str,
|
||||||
encryption_key: str,
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
cache_ttl: int = 300,
|
cache_ttl: int = 300,
|
||||||
cache_early_refresh: int = 30,
|
cache_early_refresh: int = 30,
|
||||||
):
|
):
|
||||||
@@ -112,23 +112,25 @@ class TokenBrokerService:
|
|||||||
Initialize the Token Broker Service.
|
Initialize the Token Broker Service.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
storage: Database storage for refresh tokens
|
storage: Database storage for refresh tokens (handles encryption internally)
|
||||||
oidc_discovery_url: OIDC provider discovery URL
|
oidc_discovery_url: OIDC provider discovery URL
|
||||||
nextcloud_host: Nextcloud server URL
|
nextcloud_host: Nextcloud server URL
|
||||||
encryption_key: Fernet key for token encryption
|
client_id: OAuth client ID for token operations
|
||||||
|
client_secret: OAuth client secret for token operations
|
||||||
cache_ttl: Cache TTL in seconds (default: 5 minutes)
|
cache_ttl: Cache TTL in seconds (default: 5 minutes)
|
||||||
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
|
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
|
||||||
"""
|
"""
|
||||||
self.storage = storage
|
self.storage = storage
|
||||||
self.oidc_discovery_url = oidc_discovery_url
|
self.oidc_discovery_url = oidc_discovery_url
|
||||||
self.nextcloud_host = nextcloud_host
|
self.nextcloud_host = nextcloud_host
|
||||||
self.fernet = Fernet(
|
self.client_id = client_id
|
||||||
encryption_key.encode()
|
self.client_secret = client_secret
|
||||||
if isinstance(encryption_key, str)
|
|
||||||
else encryption_key
|
|
||||||
)
|
|
||||||
self.cache = TokenCache(cache_ttl, cache_early_refresh)
|
self.cache = TokenCache(cache_ttl, cache_early_refresh)
|
||||||
self._oidc_config = None
|
self._oidc_config = None
|
||||||
|
|
||||||
|
# Per-user locks for token refresh operations (prevents race conditions)
|
||||||
|
self._user_refresh_locks: dict[str, anyio.Lock] = {}
|
||||||
|
self._locks_lock = anyio.Lock() # Protects the locks dict itself
|
||||||
self._http_client = None
|
self._http_client = None
|
||||||
|
|
||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
@@ -139,6 +141,24 @@ class TokenBrokerService:
|
|||||||
)
|
)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|
||||||
|
async def _get_user_refresh_lock(self, user_id: str) -> anyio.Lock:
|
||||||
|
"""
|
||||||
|
Get or create a lock for a specific user's refresh operations.
|
||||||
|
|
||||||
|
This prevents race conditions when multiple concurrent requests
|
||||||
|
attempt to refresh the same user's token simultaneously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID to get lock for
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
anyio.Lock for this user's refresh operations
|
||||||
|
"""
|
||||||
|
async with self._locks_lock:
|
||||||
|
if user_id not in self._user_refresh_locks:
|
||||||
|
self._user_refresh_locks[user_id] = anyio.Lock()
|
||||||
|
return self._user_refresh_locks[user_id]
|
||||||
|
|
||||||
async def _get_oidc_config(self) -> dict:
|
async def _get_oidc_config(self) -> dict:
|
||||||
"""Get OIDC configuration from discovery endpoint."""
|
"""Get OIDC configuration from discovery endpoint."""
|
||||||
if self._oidc_config is None:
|
if self._oidc_config is None:
|
||||||
@@ -148,6 +168,37 @@ 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.
|
||||||
@@ -180,9 +231,8 @@ class TokenBrokerService:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decrypt refresh token
|
# storage.get_refresh_token() returns already-decrypted token
|
||||||
encrypted_token = refresh_data["refresh_token"]
|
refresh_token = refresh_data["refresh_token"]
|
||||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
|
||||||
|
|
||||||
# Exchange refresh token for new access token
|
# Exchange refresh token for new access token
|
||||||
access_token, expires_in = await self._refresh_access_token(refresh_token)
|
access_token, expires_in = await self._refresh_access_token(refresh_token)
|
||||||
@@ -271,41 +321,79 @@ class TokenBrokerService:
|
|||||||
"""
|
"""
|
||||||
# Check cache first (background tokens can be cached)
|
# Check cache first (background tokens can be cached)
|
||||||
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
|
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
|
||||||
|
refresh_in_progress_key = f"{user_id}:refresh_in_progress"
|
||||||
|
|
||||||
cached_token = await self.cache.get(cache_key)
|
cached_token = await self.cache.get(cache_key)
|
||||||
if cached_token:
|
if cached_token:
|
||||||
return cached_token
|
return cached_token
|
||||||
|
|
||||||
# Get stored refresh token
|
# Acquire per-user lock BEFORE refresh operation to prevent race conditions
|
||||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
refresh_lock = await self._get_user_refresh_lock(user_id)
|
||||||
if not refresh_data:
|
async with refresh_lock:
|
||||||
logger.info(f"No refresh token found for user {user_id}")
|
# Double-check cache after acquiring lock
|
||||||
return None
|
# (another thread may have refreshed while we waited)
|
||||||
|
cached_token = await self.cache.get(cache_key)
|
||||||
|
if cached_token:
|
||||||
|
logger.debug(
|
||||||
|
f"Token found in cache after lock acquisition for user {user_id}"
|
||||||
|
)
|
||||||
|
return cached_token
|
||||||
|
|
||||||
try:
|
# Check if another thread is currently refreshing
|
||||||
# Decrypt refresh token
|
if await self.cache.get(refresh_in_progress_key):
|
||||||
encrypted_token = refresh_data["refresh_token"]
|
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
|
||||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
await anyio.sleep(0.1) # Brief wait for in-progress refresh
|
||||||
|
# Check cache one more time after wait
|
||||||
|
cached_token = await self.cache.get(cache_key)
|
||||||
|
if cached_token:
|
||||||
|
logger.debug(
|
||||||
|
f"Token refreshed by another thread for user {user_id}"
|
||||||
|
)
|
||||||
|
return cached_token
|
||||||
|
|
||||||
# Get token with specific scopes for background operation
|
# Mark refresh as in-progress
|
||||||
access_token, expires_in = await self._refresh_access_token_with_scopes(
|
await self.cache.set(refresh_in_progress_key, "true", expires_in=5)
|
||||||
refresh_token, required_scopes
|
|
||||||
)
|
|
||||||
|
|
||||||
# Cache the background token
|
try:
|
||||||
await self.cache.set(cache_key, access_token, expires_in)
|
# Get stored refresh token
|
||||||
|
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||||
|
if not refresh_data:
|
||||||
|
logger.info(f"No refresh token found for user {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
logger.info(
|
# storage.get_refresh_token() returns already-decrypted token
|
||||||
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
refresh_token = refresh_data["refresh_token"]
|
||||||
)
|
|
||||||
|
|
||||||
return access_token
|
# Get token with specific scopes for background operation
|
||||||
|
# Pass user_id to enable refresh token rotation storage
|
||||||
|
access_token, expires_in = await self._refresh_access_token_with_scopes(
|
||||||
|
refresh_token, required_scopes, user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
# Cache the background token
|
||||||
logger.error(f"Failed to get background token for user {user_id}: {e}")
|
await self.cache.set(cache_key, access_token, expires_in)
|
||||||
await self.cache.invalidate(cache_key)
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]:
|
logger.info(
|
||||||
|
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return access_token
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to get background token for user {user_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await self.cache.invalidate(cache_key)
|
||||||
|
return None
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Always clear the in-progress marker
|
||||||
|
await self.cache.invalidate(refresh_in_progress_key)
|
||||||
|
|
||||||
|
async def _refresh_access_token(
|
||||||
|
self, refresh_token: str, user_id: str | None = None
|
||||||
|
) -> Tuple[str, int]:
|
||||||
"""
|
"""
|
||||||
Exchange refresh token for new access token.
|
Exchange refresh token for new access token.
|
||||||
|
|
||||||
@@ -313,20 +401,24 @@ class TokenBrokerService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
refresh_token: The refresh token
|
refresh_token: The refresh token
|
||||||
|
user_id: If provided, store the rotated refresh token for this user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
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 = config["token_endpoint"]
|
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||||
|
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
# Request new access token using refresh token
|
# Request new access token using refresh token
|
||||||
|
# Include client credentials as required by most OAuth servers
|
||||||
data = {
|
data = {
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
"scope": "openid profile email notes:read notes:write calendar:read calendar:write",
|
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
@@ -345,42 +437,69 @@ class TokenBrokerService:
|
|||||||
access_token = token_data["access_token"]
|
access_token = token_data["access_token"]
|
||||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||||
|
|
||||||
# Validate audience
|
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
|
||||||
await self._validate_token_audience(access_token, "nextcloud")
|
new_refresh_token = token_data.get("refresh_token")
|
||||||
|
if user_id and new_refresh_token and new_refresh_token != refresh_token:
|
||||||
|
# Calculate expiry as Unix timestamp (90 days from now)
|
||||||
|
expires_at = int(
|
||||||
|
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||||
|
)
|
||||||
|
await self.storage.store_refresh_token(
|
||||||
|
user_id=user_id,
|
||||||
|
refresh_token=new_refresh_token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||||
|
|
||||||
|
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||||
|
|
||||||
logger.info(f"Refreshed access token (expires in {expires_in}s)")
|
logger.info(f"Refreshed access token (expires in {expires_in}s)")
|
||||||
return access_token, expires_in
|
return access_token, expires_in
|
||||||
|
|
||||||
async def _refresh_access_token_with_scopes(
|
async def _refresh_access_token_with_scopes(
|
||||||
self, refresh_token: str, required_scopes: list[str]
|
self, refresh_token: str, required_scopes: list[str], user_id: str | None = None
|
||||||
) -> Tuple[str, int]:
|
) -> Tuple[str, int]:
|
||||||
"""
|
"""
|
||||||
Exchange refresh token for new access token with specific scopes.
|
Exchange refresh token for new access token with specific scopes.
|
||||||
|
|
||||||
This method implements scope downscoping for least privilege.
|
This method implements scope downscoping for least privilege.
|
||||||
|
|
||||||
|
IMPORTANT: Nextcloud OIDC rotates refresh tokens on every use (one-time use).
|
||||||
|
When user_id is provided, this method stores the new refresh token returned
|
||||||
|
by Nextcloud to ensure subsequent refresh operations succeed.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
refresh_token: The refresh token
|
refresh_token: The refresh token
|
||||||
required_scopes: Minimal scopes needed for this operation
|
required_scopes: Minimal scopes needed for this operation
|
||||||
|
user_id: If provided, store the rotated refresh token for this user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
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 = config["token_endpoint"]
|
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||||
|
|
||||||
client = await self._get_http_client()
|
client = await self._get_http_client()
|
||||||
|
|
||||||
# Always include basic OpenID scopes
|
# Always include basic OpenID scopes + offline_access to get new refresh token
|
||||||
scopes = list(set(["openid", "profile", "email"] + required_scopes))
|
scopes = list(
|
||||||
|
set(["openid", "profile", "email", "offline_access"] + required_scopes)
|
||||||
|
)
|
||||||
|
|
||||||
# Request new access token with specific scopes
|
# Request new access token with specific scopes
|
||||||
|
# Include client credentials as required by most OAuth servers
|
||||||
data = {
|
data = {
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refresh_token,
|
"refresh_token": refresh_token,
|
||||||
"scope": " ".join(scopes),
|
"scope": " ".join(scopes),
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..."
|
||||||
|
)
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=data,
|
data=data,
|
||||||
@@ -391,14 +510,29 @@ class TokenBrokerService:
|
|||||||
logger.error(
|
logger.error(
|
||||||
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
|
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
|
||||||
)
|
)
|
||||||
|
logger.error(f" client_id used: {self.client_id[:16]}...")
|
||||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||||
|
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
access_token = token_data["access_token"]
|
access_token = token_data["access_token"]
|
||||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||||
|
|
||||||
# Validate audience
|
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
|
||||||
await self._validate_token_audience(access_token, "nextcloud")
|
new_refresh_token = token_data.get("refresh_token")
|
||||||
|
if user_id and new_refresh_token and new_refresh_token != refresh_token:
|
||||||
|
# Store the new refresh token for future use
|
||||||
|
# Calculate expiry as Unix timestamp (90 days from now)
|
||||||
|
expires_at = int(
|
||||||
|
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||||
|
)
|
||||||
|
await self.storage.store_refresh_token(
|
||||||
|
user_id=user_id,
|
||||||
|
refresh_token=new_refresh_token,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||||
|
|
||||||
|
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
|
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
|
||||||
@@ -453,11 +587,8 @@ class TokenBrokerService:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Decrypt current refresh token
|
# storage.get_refresh_token() returns already-decrypted token
|
||||||
encrypted_token = refresh_data["refresh_token"]
|
current_refresh_token = refresh_data["refresh_token"]
|
||||||
current_refresh_token = self.fernet.decrypt(
|
|
||||||
encrypted_token.encode()
|
|
||||||
).decode()
|
|
||||||
|
|
||||||
# Get OIDC configuration
|
# Get OIDC configuration
|
||||||
config = await self._get_oidc_config()
|
config = await self._get_oidc_config()
|
||||||
@@ -486,13 +617,15 @@ class TokenBrokerService:
|
|||||||
new_refresh_token = token_data.get("refresh_token")
|
new_refresh_token = token_data.get("refresh_token")
|
||||||
|
|
||||||
if new_refresh_token and new_refresh_token != current_refresh_token:
|
if new_refresh_token and new_refresh_token != current_refresh_token:
|
||||||
# Encrypt and store new refresh token
|
# storage.store_refresh_token() handles encryption internally
|
||||||
encrypted_new = self.fernet.encrypt(new_refresh_token.encode()).decode()
|
# Convert datetime to Unix timestamp (int) for database storage
|
||||||
|
expires_at = int(
|
||||||
|
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||||
|
)
|
||||||
await self.storage.store_refresh_token(
|
await self.storage.store_refresh_token(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
refresh_token=encrypted_new,
|
refresh_token=new_refresh_token,
|
||||||
expires_at=datetime.now(timezone.utc)
|
expires_at=expires_at,
|
||||||
+ timedelta(days=90), # 90-day expiry
|
|
||||||
)
|
)
|
||||||
logger.info(f"Rotated master refresh token for user {user_id}")
|
logger.info(f"Rotated master refresh token for user {user_id}")
|
||||||
|
|
||||||
@@ -536,11 +669,8 @@ class TokenBrokerService:
|
|||||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||||
if refresh_data:
|
if refresh_data:
|
||||||
try:
|
try:
|
||||||
# Attempt to revoke at IdP
|
# storage.get_refresh_token() returns already-decrypted token
|
||||||
encrypted_token = refresh_data["refresh_token"]
|
refresh_token = refresh_data["refresh_token"]
|
||||||
refresh_token = self.fernet.decrypt(
|
|
||||||
encrypted_token.encode()
|
|
||||||
).decode()
|
|
||||||
await self._revoke_token_at_idp(refresh_token)
|
await self._revoke_token_at_idp(refresh_token)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to revoke at IdP: {e}")
|
logger.warning(f"Failed to revoke at IdP: {e}")
|
||||||
|
|||||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Introspection requires client authentication
|
# Introspection requires client authentication
|
||||||
|
client_id = self.settings.oidc_client_id
|
||||||
|
client_secret = self.settings.oidc_client_secret
|
||||||
|
assert client_id is not None and client_secret is not None
|
||||||
response = await self.http_client.post(
|
response = await self.http_client.post(
|
||||||
self.introspection_uri,
|
self.introspection_uri,
|
||||||
data={"token": token},
|
data={"token": token},
|
||||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
"title": r.title,
|
"title": r.title,
|
||||||
"excerpt": r.excerpt,
|
"excerpt": r.excerpt,
|
||||||
"score": r.score,
|
"score": r.score,
|
||||||
|
"metadata": r.metadata,
|
||||||
}
|
}
|
||||||
for r in search_results
|
for r in search_results
|
||||||
],
|
],
|
||||||
@@ -458,6 +459,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
), # Raw score from algorithm
|
), # Raw score from algorithm
|
||||||
"chunk_start_offset": r.chunk_start_offset,
|
"chunk_start_offset": r.chunk_start_offset,
|
||||||
"chunk_end_offset": r.chunk_end_offset,
|
"chunk_end_offset": r.chunk_end_offset,
|
||||||
|
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
|
||||||
}
|
}
|
||||||
for r in search_results
|
for r in search_results
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
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
|
||||||
return httpx.AsyncClient(
|
return httpx.AsyncClient(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
|
|||||||
+191
-1
@@ -253,5 +253,195 @@ def run(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def db():
|
||||||
|
"""Database migration management commands."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@db.command()
|
||||||
|
@click.option(
|
||||||
|
"--database-path",
|
||||||
|
"-d",
|
||||||
|
envvar="TOKEN_STORAGE_DB",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--revision",
|
||||||
|
"-r",
|
||||||
|
default="head",
|
||||||
|
show_default=True,
|
||||||
|
help="Target revision (default: head for latest)",
|
||||||
|
)
|
||||||
|
def upgrade(database_path: str, revision: str):
|
||||||
|
"""Upgrade database to a specific revision.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
# Upgrade to latest version
|
||||||
|
$ nextcloud-mcp-server db upgrade
|
||||||
|
|
||||||
|
# Upgrade to specific revision
|
||||||
|
$ nextcloud-mcp-server db upgrade --revision 001
|
||||||
|
|
||||||
|
# Use custom database path
|
||||||
|
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.migrations import upgrade_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
click.echo(f"Upgrading database to revision: {revision}")
|
||||||
|
upgrade_database(database_path, revision)
|
||||||
|
click.echo(click.style("✓ Database upgraded successfully", fg="green"))
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"✗ Upgrade failed: {e}", fg="red"), err=True)
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@db.command()
|
||||||
|
@click.option(
|
||||||
|
"--database-path",
|
||||||
|
"-d",
|
||||||
|
envvar="TOKEN_STORAGE_DB",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Path to token storage database",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--revision",
|
||||||
|
"-r",
|
||||||
|
default="-1",
|
||||||
|
show_default=True,
|
||||||
|
help="Target revision (default: -1 for previous version)",
|
||||||
|
)
|
||||||
|
@click.confirmation_option(
|
||||||
|
prompt="Are you sure you want to downgrade the database? This may result in data loss."
|
||||||
|
)
|
||||||
|
def downgrade(database_path: str, revision: str):
|
||||||
|
"""Downgrade database to a specific revision.
|
||||||
|
|
||||||
|
WARNING: This may result in data loss! Use with caution.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
# Downgrade by one version
|
||||||
|
$ nextcloud-mcp-server db downgrade
|
||||||
|
|
||||||
|
# Downgrade to specific revision
|
||||||
|
$ nextcloud-mcp-server db downgrade --revision 001
|
||||||
|
|
||||||
|
# Downgrade to base (empty database)
|
||||||
|
$ nextcloud-mcp-server db downgrade --revision base
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.migrations import downgrade_database
|
||||||
|
|
||||||
|
try:
|
||||||
|
click.echo(f"Downgrading database to revision: {revision}")
|
||||||
|
downgrade_database(database_path, revision)
|
||||||
|
click.echo(click.style("✓ Database downgraded successfully", fg="green"))
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"✗ Downgrade failed: {e}", fg="red"), err=True)
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@db.command()
|
||||||
|
@click.option(
|
||||||
|
"--database-path",
|
||||||
|
"-d",
|
||||||
|
envvar="TOKEN_STORAGE_DB",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Path to token storage database",
|
||||||
|
)
|
||||||
|
def current(database_path: str):
|
||||||
|
"""Show current database revision.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Example:
|
||||||
|
$ nextcloud-mcp-server db current
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.migrations import get_current_revision
|
||||||
|
|
||||||
|
try:
|
||||||
|
revision = get_current_revision(database_path)
|
||||||
|
if revision:
|
||||||
|
click.echo(f"Current revision: {click.style(revision, fg='cyan')}")
|
||||||
|
else:
|
||||||
|
click.echo(
|
||||||
|
click.style(
|
||||||
|
"Database is not versioned (no alembic_version table)", fg="yellow"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(
|
||||||
|
click.style(f"✗ Failed to get current revision: {e}", fg="red"), err=True
|
||||||
|
)
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@db.command()
|
||||||
|
@click.option(
|
||||||
|
"--database-path",
|
||||||
|
"-d",
|
||||||
|
envvar="TOKEN_STORAGE_DB",
|
||||||
|
default="/app/data/tokens.db",
|
||||||
|
show_default=True,
|
||||||
|
help="Path to token storage database",
|
||||||
|
)
|
||||||
|
def history(database_path: str):
|
||||||
|
"""Show migration history.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Example:
|
||||||
|
$ nextcloud-mcp-server db history
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.migrations import show_migration_history
|
||||||
|
|
||||||
|
try:
|
||||||
|
click.echo("Migration history:")
|
||||||
|
show_migration_history(database_path)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True)
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@db.command()
|
||||||
|
@click.argument("message")
|
||||||
|
def migrate(message: str):
|
||||||
|
"""Create a new migration script (developers only).
|
||||||
|
|
||||||
|
The MESSAGE argument describes the changes in this migration.
|
||||||
|
|
||||||
|
\b
|
||||||
|
Examples:
|
||||||
|
$ nextcloud-mcp-server db migrate "add user preferences table"
|
||||||
|
$ nextcloud-mcp-server db migrate "add index on refresh_tokens.user_id"
|
||||||
|
|
||||||
|
Note: You must manually edit the generated migration file to add SQL statements.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.migrations import create_migration
|
||||||
|
|
||||||
|
try:
|
||||||
|
click.echo(f"Creating new migration: {message}")
|
||||||
|
create_migration(message)
|
||||||
|
click.echo(click.style("✓ Migration created successfully", fg="green"))
|
||||||
|
click.echo(
|
||||||
|
"Edit the migration file in alembic/versions/ to add upgrade/downgrade SQL."
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
click.echo(
|
||||||
|
click.style(f"✗ Failed to create migration: {e}", fg="red"), err=True
|
||||||
|
)
|
||||||
|
raise click.ClickException(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# Create CLI group with subcommands
|
||||||
|
cli = click.Group()
|
||||||
|
cli.add_command(run)
|
||||||
|
cli.add_command(db)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run()
|
cli()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
|||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
from .deck import DeckClient
|
from .deck import DeckClient
|
||||||
from .groups import GroupsClient
|
from .groups import GroupsClient
|
||||||
|
from .news import NewsClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .sharing import SharingClient
|
from .sharing import SharingClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
|||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
self.cookbook = CookbookClient(self._client, username)
|
self.cookbook = CookbookClient(self._client, username)
|
||||||
self.deck = DeckClient(self._client, username)
|
self.deck = DeckClient(self._client, username)
|
||||||
|
self.news = NewsClient(self._client, username)
|
||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
self.sharing = SharingClient(self._client, username)
|
self.sharing = SharingClient(self._client, username)
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""Client for Nextcloud News app operations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .base import BaseNextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemType(IntEnum):
|
||||||
|
"""Type constants for News API item queries."""
|
||||||
|
|
||||||
|
FEED = 0 # Single feed
|
||||||
|
FOLDER = 1 # Folder and its feeds
|
||||||
|
STARRED = 2 # All starred items
|
||||||
|
ALL = 3 # All items
|
||||||
|
|
||||||
|
|
||||||
|
class NewsClient(BaseNextcloudClient):
|
||||||
|
"""Client for Nextcloud News app operations."""
|
||||||
|
|
||||||
|
app_name = "news"
|
||||||
|
API_BASE = "/apps/news/api/v1-3"
|
||||||
|
|
||||||
|
# --- Folders ---
|
||||||
|
|
||||||
|
async def get_folders(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get all folders."""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/folders")
|
||||||
|
return response.json().get("folders", [])
|
||||||
|
|
||||||
|
async def create_folder(self, name: str) -> dict[str, Any]:
|
||||||
|
"""Create a new folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Folder name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created folder data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 409 if folder name already exists,
|
||||||
|
422 if name is empty
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/folders", json={"name": name}
|
||||||
|
)
|
||||||
|
folders = response.json().get("folders", [])
|
||||||
|
return folders[0] if folders else {}
|
||||||
|
|
||||||
|
async def rename_folder(self, folder_id: int, name: str) -> None:
|
||||||
|
"""Rename a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
name: New folder name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found, 409 if name exists
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_folder(self, folder_id: int) -> None:
|
||||||
|
"""Delete a folder and all its feeds/items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found
|
||||||
|
"""
|
||||||
|
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
|
||||||
|
|
||||||
|
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items in a folder as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
newest_item_id: ID of newest item to mark read (prevents marking
|
||||||
|
items user hasn't seen yet)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/folders/{folder_id}/read",
|
||||||
|
json={"newestItemId": newest_item_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Feeds ---
|
||||||
|
|
||||||
|
async def get_feeds(self) -> dict[str, Any]:
|
||||||
|
"""Get all feeds with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys:
|
||||||
|
- feeds: List of feed objects
|
||||||
|
- starredCount: Number of starred items
|
||||||
|
- newestItemId: ID of newest item (omitted if no items)
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def create_feed(
|
||||||
|
self, url: str, folder_id: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Subscribe to a new feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Feed URL
|
||||||
|
folder_id: Optional folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created feed data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
|
||||||
|
"""
|
||||||
|
body: dict[str, Any] = {"url": url}
|
||||||
|
if folder_id is not None:
|
||||||
|
body["folderId"] = folder_id
|
||||||
|
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
|
||||||
|
data = response.json()
|
||||||
|
feeds = data.get("feeds", [])
|
||||||
|
return feeds[0] if feeds else {}
|
||||||
|
|
||||||
|
async def delete_feed(self, feed_id: int) -> None:
|
||||||
|
"""Unsubscribe from a feed (deletes all items).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
|
||||||
|
|
||||||
|
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
|
||||||
|
"""Move a feed to a different folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
folder_id: Target folder ID (None for root)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/move",
|
||||||
|
json={"folderId": folder_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rename_feed(self, feed_id: int, title: str) -> None:
|
||||||
|
"""Rename a feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
title: New feed title
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/rename",
|
||||||
|
json={"feedTitle": title},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items in a feed as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
newest_item_id: ID of newest item to mark read
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/read",
|
||||||
|
json={"newestItemId": newest_item_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Items ---
|
||||||
|
|
||||||
|
async def get_items(
|
||||||
|
self,
|
||||||
|
batch_size: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
type_: int = NewsItemType.ALL,
|
||||||
|
id_: int = 0,
|
||||||
|
get_read: bool = True,
|
||||||
|
oldest_first: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get items (articles) with filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch_size: Number of items to return (-1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
type_: Item type filter (NewsItemType)
|
||||||
|
id_: Feed/folder ID (ignored for STARRED/ALL types)
|
||||||
|
get_read: Include read items
|
||||||
|
oldest_first: Sort oldest first instead of newest
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of item objects
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"batchSize": batch_size,
|
||||||
|
"offset": offset,
|
||||||
|
"type": type_,
|
||||||
|
"id": id_,
|
||||||
|
"getRead": str(get_read).lower(),
|
||||||
|
"oldestFirst": str(oldest_first).lower(),
|
||||||
|
}
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"{self.API_BASE}/items", params=params
|
||||||
|
)
|
||||||
|
return response.json().get("items", [])
|
||||||
|
|
||||||
|
async def get_item(self, item_id: int) -> dict[str, Any]:
|
||||||
|
"""Get a specific item by ID.
|
||||||
|
|
||||||
|
Note: The News API doesn't have a direct single-item endpoint,
|
||||||
|
so we fetch all items and filter. For efficiency, consider
|
||||||
|
caching or using get_items with specific feed if known.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If item not found
|
||||||
|
"""
|
||||||
|
# Fetch all items and find the one we need
|
||||||
|
# This is inefficient but the API doesn't provide a direct endpoint
|
||||||
|
items = await self.get_items(batch_size=-1, get_read=True)
|
||||||
|
for item in items:
|
||||||
|
if item.get("id") == item_id:
|
||||||
|
return item
|
||||||
|
raise ValueError(f"Item {item_id} not found")
|
||||||
|
|
||||||
|
async def get_updated_items(
|
||||||
|
self,
|
||||||
|
last_modified: int,
|
||||||
|
type_: int = NewsItemType.ALL,
|
||||||
|
id_: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get items modified since a timestamp (for delta sync).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_modified: Unix timestamp (seconds or microseconds)
|
||||||
|
type_: Item type filter
|
||||||
|
id_: Feed/folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of modified items (includes deleted items)
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"lastModified": last_modified,
|
||||||
|
"type": type_,
|
||||||
|
"id": id_,
|
||||||
|
}
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"{self.API_BASE}/items/updated", params=params
|
||||||
|
)
|
||||||
|
return response.json().get("items", [])
|
||||||
|
|
||||||
|
async def mark_item_read(self, item_id: int) -> None:
|
||||||
|
"""Mark a single item as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
|
||||||
|
|
||||||
|
async def mark_item_unread(self, item_id: int) -> None:
|
||||||
|
"""Mark a single item as unread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
|
||||||
|
|
||||||
|
async def star_item(self, item_id: int) -> None:
|
||||||
|
"""Star (favorite) a single item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
|
||||||
|
|
||||||
|
async def unstar_item(self, item_id: int) -> None:
|
||||||
|
"""Unstar a single item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
|
||||||
|
|
||||||
|
async def mark_items_read(self, item_ids: list[int]) -> None:
|
||||||
|
"""Mark multiple items as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_items_unread(self, item_ids: list[int]) -> None:
|
||||||
|
"""Mark multiple items as unread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/items/unread/multiple",
|
||||||
|
json={"itemIds": item_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def star_items(self, item_ids: list[int]) -> None:
|
||||||
|
"""Star multiple items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unstar_items(self, item_ids: list[int]) -> None:
|
||||||
|
"""Unstar multiple items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/items/unstar/multiple",
|
||||||
|
json={"itemIds": item_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_all_read(self, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
newest_item_id: ID of newest item to mark read
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Status ---
|
||||||
|
|
||||||
|
async def get_status(self) -> dict[str, Any]:
|
||||||
|
"""Get News app status and configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with version and warnings
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/status")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_version(self) -> str:
|
||||||
|
"""Get News app version.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version string (e.g., "25.0.0")
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/version")
|
||||||
|
return response.json().get("version", "")
|
||||||
@@ -1174,13 +1174,17 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||||
tag_info = {
|
tag_info = {
|
||||||
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
|
"id": int(tag_id_elem.text)
|
||||||
|
if tag_id_elem is not None and tag_id_elem.text is not None
|
||||||
|
else None,
|
||||||
"name": display_name_elem.text,
|
"name": display_name_elem.text,
|
||||||
"userVisible": user_visible_elem.text.lower() == "true"
|
"userVisible": user_visible_elem.text.lower() == "true"
|
||||||
if user_visible_elem is not None
|
if user_visible_elem is not None
|
||||||
|
and user_visible_elem.text is not None
|
||||||
else True,
|
else True,
|
||||||
"userAssignable": user_assignable_elem.text.lower() == "true"
|
"userAssignable": user_assignable_elem.text.lower() == "true"
|
||||||
if user_assignable_elem is not None
|
if user_assignable_elem is not None
|
||||||
|
and user_assignable_elem.text is not None
|
||||||
else True,
|
else True,
|
||||||
}
|
}
|
||||||
logger.debug(f"Found tag '{tag_name}' with ID {tag_info['id']}")
|
logger.debug(f"Found tag '{tag_name}' with ID {tag_info['id']}")
|
||||||
@@ -1369,7 +1373,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_info = {
|
file_info = {
|
||||||
"id": int(fileid_elem.text) if fileid_elem is not None else None,
|
"id": int(fileid_elem.text)
|
||||||
|
if fileid_elem is not None and fileid_elem.text is not None
|
||||||
|
else None,
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": displayname_elem.text
|
"name": displayname_elem.text
|
||||||
if displayname_elem is not None
|
if displayname_elem is not None
|
||||||
|
|||||||
@@ -163,6 +163,12 @@ def get_document_processor_config() -> dict[str, Any]:
|
|||||||
class Settings:
|
class Settings:
|
||||||
"""Application settings from environment variables."""
|
"""Application settings from environment variables."""
|
||||||
|
|
||||||
|
# Deployment mode (ADR-021: explicit mode selection)
|
||||||
|
# Optional: If not set, mode is auto-detected from other settings
|
||||||
|
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
|
||||||
|
# oauth_token_exchange, smithery
|
||||||
|
deployment_mode: Optional[str] = None
|
||||||
|
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url: Optional[str] = None
|
oidc_discovery_url: Optional[str] = None
|
||||||
oidc_client_id: Optional[str] = None
|
oidc_client_id: Optional[str] = None
|
||||||
@@ -187,6 +193,11 @@ class Settings:
|
|||||||
enable_token_exchange: bool = False
|
enable_token_exchange: bool = False
|
||||||
enable_offline_access: bool = False
|
enable_offline_access: bool = False
|
||||||
|
|
||||||
|
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
|
||||||
|
# When enabled, MCP server extracts BasicAuth credentials from request headers
|
||||||
|
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||||
|
enable_multi_user_basic_auth: bool = False
|
||||||
|
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||||
|
|
||||||
@@ -205,6 +216,7 @@ class Settings:
|
|||||||
vector_sync_scan_interval: int = 300 # seconds (5 minutes)
|
vector_sync_scan_interval: int = 300 # seconds (5 minutes)
|
||||||
vector_sync_processor_workers: int = 3
|
vector_sync_processor_workers: int = 3
|
||||||
vector_sync_queue_max_size: int = 10000
|
vector_sync_queue_max_size: int = 10000
|
||||||
|
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||||
|
|
||||||
# Qdrant settings (mutually exclusive modes)
|
# Qdrant settings (mutually exclusive modes)
|
||||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||||
@@ -345,13 +357,131 @@ class Settings:
|
|||||||
return f"{deployment_id}-{model_name}"
|
return f"{deployment_id}-{model_name}"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_semantic_search_enabled() -> bool:
|
||||||
|
"""Get semantic search enabled status, supporting both old and new variable names.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ENABLE_SEMANTIC_SEARCH (new, preferred)
|
||||||
|
- VECTOR_SYNC_ENABLED (old, deprecated)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if semantic search should be enabled
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
|
||||||
|
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
|
||||||
|
|
||||||
|
if new_value and old_value:
|
||||||
|
logger.warning(
|
||||||
|
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
|
||||||
|
"Using ENABLE_SEMANTIC_SEARCH. "
|
||||||
|
"VECTOR_SYNC_ENABLED is deprecated and will be removed in v1.0.0."
|
||||||
|
)
|
||||||
|
elif old_value and not new_value:
|
||||||
|
logger.warning(
|
||||||
|
"VECTOR_SYNC_ENABLED is deprecated. "
|
||||||
|
"Please use ENABLE_SEMANTIC_SEARCH instead. "
|
||||||
|
"Support for VECTOR_SYNC_ENABLED will be removed in v1.0.0."
|
||||||
|
)
|
||||||
|
|
||||||
|
return new_value or old_value
|
||||||
|
|
||||||
|
|
||||||
|
def _is_multi_user_mode() -> bool:
|
||||||
|
"""Detect if this is a multi-user deployment mode.
|
||||||
|
|
||||||
|
Multi-user modes are:
|
||||||
|
- Multi-user BasicAuth (ENABLE_MULTI_USER_BASIC_AUTH=true)
|
||||||
|
- OAuth Single-Audience (no username/password set)
|
||||||
|
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
|
||||||
|
|
||||||
|
Single-user modes are:
|
||||||
|
- Single-user BasicAuth (username and password both set)
|
||||||
|
- Smithery Stateless (SMITHERY_DEPLOYMENT=true)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if multi-user mode detected
|
||||||
|
"""
|
||||||
|
# Smithery is always single-user (stateless)
|
||||||
|
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Multi-user BasicAuth explicitly enabled
|
||||||
|
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Token exchange implies OAuth multi-user
|
||||||
|
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If both username and password are set, it's single-user BasicAuth
|
||||||
|
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
|
||||||
|
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
|
||||||
|
if has_username and has_password:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Otherwise, assume OAuth multi-user (default when no credentials provided)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _get_background_operations_enabled() -> bool:
|
||||||
|
"""Get background operations enabled status with auto-enablement for semantic search.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ENABLE_BACKGROUND_OPERATIONS (new, preferred)
|
||||||
|
- ENABLE_OFFLINE_ACCESS (old, deprecated)
|
||||||
|
- Auto-enabled if ENABLE_SEMANTIC_SEARCH=true in multi-user modes
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if background operations should be enabled
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Check new and old variable names
|
||||||
|
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
|
||||||
|
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
|
||||||
|
|
||||||
|
if explicit and legacy:
|
||||||
|
logger.warning(
|
||||||
|
"Both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS are set. "
|
||||||
|
"Using ENABLE_BACKGROUND_OPERATIONS. "
|
||||||
|
"ENABLE_OFFLINE_ACCESS is deprecated and will be removed in v1.0.0."
|
||||||
|
)
|
||||||
|
elif legacy and not explicit:
|
||||||
|
logger.warning(
|
||||||
|
"ENABLE_OFFLINE_ACCESS is deprecated. "
|
||||||
|
"Please use ENABLE_BACKGROUND_OPERATIONS instead. "
|
||||||
|
"Support for ENABLE_OFFLINE_ACCESS will be removed in v1.0.0."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Auto-enable if semantic search is enabled in multi-user mode
|
||||||
|
semantic_search_enabled = _get_semantic_search_enabled()
|
||||||
|
is_multi_user = _is_multi_user_mode()
|
||||||
|
auto_enabled = semantic_search_enabled and is_multi_user
|
||||||
|
|
||||||
|
if auto_enabled and not (explicit or legacy):
|
||||||
|
logger.info(
|
||||||
|
"Automatically enabled background operations for semantic search in multi-user mode. "
|
||||||
|
"Set ENABLE_BACKGROUND_OPERATIONS=false to disable (this will also disable semantic search)."
|
||||||
|
)
|
||||||
|
|
||||||
|
return explicit or legacy or auto_enabled
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
"""Get application settings from environment variables.
|
"""Get application settings from environment variables.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Settings object with configuration values
|
Settings object with configuration values
|
||||||
"""
|
"""
|
||||||
|
# Get consolidated values with smart dependency resolution
|
||||||
|
enable_semantic_search = _get_semantic_search_enabled()
|
||||||
|
enable_background_operations = _get_background_operations_enabled()
|
||||||
|
|
||||||
return Settings(
|
return Settings(
|
||||||
|
# Deployment mode (ADR-021)
|
||||||
|
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
|
||||||
# OAuth/OIDC settings
|
# OAuth/OIDC settings
|
||||||
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
|
||||||
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
|
||||||
@@ -372,8 +502,10 @@ def get_settings() -> Settings:
|
|||||||
enable_token_exchange=(
|
enable_token_exchange=(
|
||||||
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
|
||||||
),
|
),
|
||||||
enable_offline_access=(
|
enable_offline_access=enable_background_operations, # Smart dependency resolution
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
# Multi-user BasicAuth pass-through mode
|
||||||
|
enable_multi_user_basic_auth=(
|
||||||
|
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||||
),
|
),
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||||
@@ -381,9 +513,7 @@ def get_settings() -> Settings:
|
|||||||
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
|
||||||
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
|
||||||
# Vector sync settings (ADR-007)
|
# Vector sync settings (ADR-007)
|
||||||
vector_sync_enabled=(
|
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
|
||||||
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
|
||||||
),
|
|
||||||
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
|
||||||
vector_sync_processor_workers=int(
|
vector_sync_processor_workers=int(
|
||||||
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
|
||||||
@@ -391,6 +521,9 @@ def get_settings() -> Settings:
|
|||||||
vector_sync_queue_max_size=int(
|
vector_sync_queue_max_size=int(
|
||||||
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
|
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
|
||||||
),
|
),
|
||||||
|
vector_sync_user_poll_interval=int(
|
||||||
|
os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60")
|
||||||
|
),
|
||||||
# Qdrant settings
|
# Qdrant settings
|
||||||
qdrant_url=os.getenv("QDRANT_URL"),
|
qdrant_url=os.getenv("QDRANT_URL"),
|
||||||
qdrant_location=os.getenv("QDRANT_LOCATION"),
|
qdrant_location=os.getenv("QDRANT_LOCATION"),
|
||||||
|
|||||||
@@ -0,0 +1,460 @@
|
|||||||
|
"""Configuration validation and mode detection for the MCP server.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- Mode detection based on configuration
|
||||||
|
- Configuration validation with clear error messages
|
||||||
|
- Single source of truth for deployment mode requirements
|
||||||
|
|
||||||
|
See ADR-020 for detailed architecture and deployment mode documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMode(Enum):
|
||||||
|
"""Authentication mode for the MCP server.
|
||||||
|
|
||||||
|
Determines how users authenticate and how the server accesses Nextcloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SINGLE_USER_BASIC = "single_user_basic"
|
||||||
|
MULTI_USER_BASIC = "multi_user_basic"
|
||||||
|
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
||||||
|
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
||||||
|
SMITHERY_STATELESS = "smithery"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModeRequirements:
|
||||||
|
"""Requirements for a deployment mode.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
required: Configuration variables that must be set
|
||||||
|
optional: Configuration variables that may be set
|
||||||
|
forbidden: Configuration variables that should not be set
|
||||||
|
conditional: Additional requirements based on feature flags
|
||||||
|
Format: {feature_flag: [required_vars]}
|
||||||
|
description: Human-readable description of the mode
|
||||||
|
"""
|
||||||
|
|
||||||
|
required: list[str]
|
||||||
|
optional: list[str]
|
||||||
|
forbidden: list[str]
|
||||||
|
conditional: dict[str, list[str]]
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
# Mode requirements definition
|
||||||
|
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||||
|
AuthMode.SINGLE_USER_BASIC: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "nextcloud_username", "nextcloud_password"],
|
||||||
|
optional=[
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
"document_chunk_size",
|
||||||
|
"document_chunk_overlap",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"vector_sync_enabled": [
|
||||||
|
# Either qdrant_url OR qdrant_location (checked in Settings.__post_init__)
|
||||||
|
# At least one embedding provider (ollama_base_url OR openai_api_key)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description="Single-user deployment with BasicAuth credentials. "
|
||||||
|
"Suitable for personal Nextcloud instances and local development.",
|
||||||
|
),
|
||||||
|
AuthMode.MULTI_USER_BASIC: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "enable_multi_user_basic_auth"],
|
||||||
|
optional=[
|
||||||
|
# Background sync with app passwords (via Astrolabe)
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_token_exchange",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
# OAuth credentials validated separately (lines 397-406) with clearer error message
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
|
# enables background operations in multi-user modes. No explicit
|
||||||
|
# enable_offline_access setting required.
|
||||||
|
},
|
||||||
|
description="Multi-user deployment with BasicAuth pass-through. "
|
||||||
|
"Users provide credentials in request headers. "
|
||||||
|
"Optional background sync using app passwords stored via Astrolabe.",
|
||||||
|
),
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE: ModeRequirements(
|
||||||
|
required=["nextcloud_host"],
|
||||||
|
optional=[
|
||||||
|
# OAuth credentials (uses DCR if not provided)
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
"oidc_discovery_url",
|
||||||
|
# Offline access
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
# Scopes
|
||||||
|
"nextcloud_oidc_scopes",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
|
# enables background operations in multi-user modes. No explicit
|
||||||
|
# enable_offline_access setting required.
|
||||||
|
},
|
||||||
|
description="OAuth multi-user deployment with single-audience tokens. "
|
||||||
|
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
||||||
|
"Uses Dynamic Client Registration if credentials not provided.",
|
||||||
|
),
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "enable_token_exchange"],
|
||||||
|
optional=[
|
||||||
|
# OAuth credentials
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
"oidc_discovery_url",
|
||||||
|
# Token exchange settings
|
||||||
|
"token_exchange_cache_ttl",
|
||||||
|
# Offline access
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
|
||||||
|
# enables background operations in multi-user modes. No explicit
|
||||||
|
# enable_offline_access setting required.
|
||||||
|
},
|
||||||
|
description="OAuth multi-user deployment with token exchange (RFC 8693). "
|
||||||
|
"MCP tokens are separate from Nextcloud tokens. "
|
||||||
|
"Server exchanges MCP token for Nextcloud token on each request.",
|
||||||
|
),
|
||||||
|
AuthMode.SMITHERY_STATELESS: ModeRequirements(
|
||||||
|
required=[], # All config from session URL params
|
||||||
|
optional=[],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_host",
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"enable_offline_access",
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
],
|
||||||
|
conditional={},
|
||||||
|
description="Stateless multi-tenant deployment for Smithery platform. "
|
||||||
|
"Configuration comes from session URL parameters. "
|
||||||
|
"No persistent storage, no OAuth, no vector sync.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||||
|
"""Detect authentication mode from configuration.
|
||||||
|
|
||||||
|
Mode detection priority (ADR-021):
|
||||||
|
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
|
||||||
|
1. Smithery (explicit flag)
|
||||||
|
2. Token exchange (most specific OAuth mode)
|
||||||
|
3. Multi-user BasicAuth
|
||||||
|
4. Single-user BasicAuth
|
||||||
|
5. OAuth single-audience (default OAuth mode)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detected AuthMode
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ADR-021: Check for explicit deployment mode first
|
||||||
|
if settings.deployment_mode:
|
||||||
|
mode_str = settings.deployment_mode.lower().strip()
|
||||||
|
|
||||||
|
# Map string to AuthMode enum
|
||||||
|
mode_map = {
|
||||||
|
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
|
||||||
|
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
|
||||||
|
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
|
"smithery": AuthMode.SMITHERY_STATELESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
if mode_str not in mode_map:
|
||||||
|
valid_modes = ", ".join(mode_map.keys())
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid MCP_DEPLOYMENT_MODE: '{settings.deployment_mode}'. "
|
||||||
|
f"Valid values: {valid_modes}"
|
||||||
|
)
|
||||||
|
|
||||||
|
explicit_mode = mode_map[mode_str]
|
||||||
|
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
|
||||||
|
return explicit_mode
|
||||||
|
|
||||||
|
# Auto-detection (existing behavior)
|
||||||
|
# Check for Smithery mode (explicit environment variable)
|
||||||
|
# Note: This checks the environment directly, not settings
|
||||||
|
# because Smithery mode has no settings-based config
|
||||||
|
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||||
|
return AuthMode.SMITHERY_STATELESS
|
||||||
|
|
||||||
|
# Check for token exchange (most specific OAuth mode)
|
||||||
|
if settings.enable_token_exchange:
|
||||||
|
return AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|
||||||
|
# Check for multi-user BasicAuth
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
return AuthMode.MULTI_USER_BASIC
|
||||||
|
|
||||||
|
# Check for single-user BasicAuth (explicit credentials)
|
||||||
|
if settings.nextcloud_username and settings.nextcloud_password:
|
||||||
|
return AuthMode.SINGLE_USER_BASIC
|
||||||
|
|
||||||
|
# Default: OAuth single-audience mode
|
||||||
|
# This is the safest multi-user mode (no credential storage)
|
||||||
|
return AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
||||||
|
"""Validate configuration for detected mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (detected_mode, list_of_errors)
|
||||||
|
Empty list means valid configuration.
|
||||||
|
"""
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
requirements = MODE_REQUIREMENTS[mode]
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
logger.debug(f"Validating configuration for mode: {mode.value}")
|
||||||
|
|
||||||
|
# Check required variables
|
||||||
|
for var in requirements.required:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] Missing required configuration: {var.upper()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check forbidden variables
|
||||||
|
for var in requirements.forbidden:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
# For bools, check if True (forbidden means must be False/unset)
|
||||||
|
# For strings, check if non-empty
|
||||||
|
is_set = False
|
||||||
|
if isinstance(value, bool):
|
||||||
|
is_set = value is True
|
||||||
|
elif isinstance(value, str):
|
||||||
|
is_set = bool(value.strip())
|
||||||
|
elif value is not None:
|
||||||
|
is_set = True
|
||||||
|
|
||||||
|
if is_set:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] Forbidden configuration: {var.upper()} "
|
||||||
|
f"should not be set in this mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check conditional requirements
|
||||||
|
for condition, required_vars in requirements.conditional.items():
|
||||||
|
# Check if the condition is enabled
|
||||||
|
condition_value = getattr(settings, condition, None)
|
||||||
|
is_enabled = False
|
||||||
|
|
||||||
|
if isinstance(condition_value, bool):
|
||||||
|
is_enabled = condition_value is True
|
||||||
|
elif isinstance(condition_value, str):
|
||||||
|
is_enabled = bool(condition_value.strip())
|
||||||
|
elif condition_value is not None:
|
||||||
|
is_enabled = True
|
||||||
|
|
||||||
|
if is_enabled:
|
||||||
|
# Check that all required vars for this condition are set
|
||||||
|
for var in required_vars:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
|
||||||
|
# For boolean requirements, check that they are True (not just set)
|
||||||
|
if hasattr(Settings, var):
|
||||||
|
field_type = type(getattr(Settings(), var, None))
|
||||||
|
if field_type is bool:
|
||||||
|
if value is not True:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] {var.upper()} must be enabled when "
|
||||||
|
f"{condition.upper()} is enabled"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For non-boolean requirements, check that they are set
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] {var.upper()} is required when "
|
||||||
|
f"{condition.upper()} is enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Special validations for specific modes
|
||||||
|
if mode == AuthMode.SINGLE_USER_BASIC:
|
||||||
|
# Validate that NEXTCLOUD_HOST doesn't have trailing slash
|
||||||
|
if settings.nextcloud_host and settings.nextcloud_host.endswith("/"):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] NEXTCLOUD_HOST should not have trailing slash: "
|
||||||
|
f"{settings.nextcloud_host}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode in [
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
|
]:
|
||||||
|
# If OAuth credentials not provided, DCR must be available
|
||||||
|
# (This is a runtime check, not a config check, so we just warn)
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
logger.info(
|
||||||
|
f"[{mode.value}] OAuth credentials not configured. "
|
||||||
|
"Will attempt Dynamic Client Registration (DCR) at startup."
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == AuthMode.MULTI_USER_BASIC:
|
||||||
|
# If background operations enabled, check for OAuth credentials (for app password retrieval)
|
||||||
|
# Allow DCR as fallback, just like OAuth modes
|
||||||
|
if settings.enable_offline_access:
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
logger.info(
|
||||||
|
f"[{mode.value}] OAuth credentials not configured. "
|
||||||
|
"Will attempt Dynamic Client Registration (DCR) at startup "
|
||||||
|
"(required for app password retrieval via Astrolabe)."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
|
||||||
|
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
|
||||||
|
# background operations in multi-user modes via smart dependency resolution
|
||||||
|
# in config.py
|
||||||
|
|
||||||
|
# Note: Embedding provider validation removed - Simple provider is always
|
||||||
|
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
|
||||||
|
# for better quality embeddings.
|
||||||
|
|
||||||
|
return mode, errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_mode_summary(mode: AuthMode) -> str:
|
||||||
|
"""Get human-readable summary of a deployment mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Deployment mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Multi-line string describing the mode
|
||||||
|
"""
|
||||||
|
requirements = MODE_REQUIREMENTS[mode]
|
||||||
|
|
||||||
|
summary_lines = [
|
||||||
|
f"Mode: {mode.value}",
|
||||||
|
f"Description: {requirements.description}",
|
||||||
|
"",
|
||||||
|
"Required configuration:",
|
||||||
|
]
|
||||||
|
|
||||||
|
if requirements.required:
|
||||||
|
for var in requirements.required:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
else:
|
||||||
|
summary_lines.append(" (none - configured via session)")
|
||||||
|
|
||||||
|
summary_lines.append("")
|
||||||
|
summary_lines.append("Optional configuration:")
|
||||||
|
|
||||||
|
if requirements.optional:
|
||||||
|
for var in requirements.optional:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
else:
|
||||||
|
summary_lines.append(" (none)")
|
||||||
|
|
||||||
|
if requirements.conditional:
|
||||||
|
summary_lines.append("")
|
||||||
|
summary_lines.append("Conditional requirements:")
|
||||||
|
for condition, vars in requirements.conditional.items():
|
||||||
|
summary_lines.append(f" When {condition.upper()} is enabled:")
|
||||||
|
for var in vars:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
|
||||||
|
return "\n".join(summary_lines)
|
||||||
@@ -67,6 +67,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
return _get_client_from_session_config(ctx)
|
return _get_client_from_session_config(ctx)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Multi-user BasicAuth pass-through mode - extract credentials from request
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
return _get_client_from_basic_auth(ctx)
|
||||||
|
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
lifespan_ctx = ctx.request_context.lifespan_context
|
||||||
|
|
||||||
# BasicAuth mode - use shared client (no token exchange)
|
# BasicAuth mode - use shared client (no token exchange)
|
||||||
@@ -177,3 +182,67 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
username=username,
|
username=username,
|
||||||
auth=BasicAuth(username, app_password),
|
auth=BasicAuth(username, app_password),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||||
|
"""
|
||||||
|
Create NextcloudClient from BasicAuth credentials in request headers.
|
||||||
|
|
||||||
|
For multi-user BasicAuth pass-through mode, this function extracts
|
||||||
|
username/password from the Authorization: Basic header (stored by
|
||||||
|
BasicAuthMiddleware) and creates a client that passes these credentials
|
||||||
|
through to Nextcloud APIs.
|
||||||
|
|
||||||
|
The credentials are NOT stored persistently - they exist only for the
|
||||||
|
duration of this request (stateless).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP request context with basic_auth in request state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient configured with BasicAuth credentials
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If BasicAuth credentials not found in request or if
|
||||||
|
NEXTCLOUD_HOST is not configured
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Validate that NEXTCLOUD_HOST is configured
|
||||||
|
if not settings.nextcloud_host:
|
||||||
|
raise ValueError(
|
||||||
|
"NEXTCLOUD_HOST environment variable must be set for multi-user BasicAuth mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract BasicAuth credentials from request state (set by BasicAuthMiddleware)
|
||||||
|
# Access scope through the request object
|
||||||
|
scope = getattr(ctx.request_context.request, "scope", None)
|
||||||
|
if scope is None:
|
||||||
|
raise ValueError("Request scope not available in context")
|
||||||
|
|
||||||
|
request_state = scope.get("state", {})
|
||||||
|
basic_auth = request_state.get("basic_auth")
|
||||||
|
|
||||||
|
if not basic_auth:
|
||||||
|
raise ValueError(
|
||||||
|
"BasicAuth credentials not found in request. "
|
||||||
|
"Ensure Authorization: Basic header is provided with valid credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
username = basic_auth.get("username")
|
||||||
|
password = basic_auth.get("password")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("Invalid BasicAuth credentials - missing username or password")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create client that passes BasicAuth credentials through to Nextcloud
|
||||||
|
# settings.nextcloud_host is guaranteed to be str after the check above
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=settings.nextcloud_host,
|
||||||
|
username=username,
|
||||||
|
auth=BasicAuth(username, password),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Database migration utilities for nextcloud-mcp-server.
|
||||||
|
|
||||||
|
This module provides helper functions for managing Alembic database migrations
|
||||||
|
programmatically. It enables automatic migration on application startup and
|
||||||
|
provides CLI integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from alembic.config import Config
|
||||||
|
|
||||||
|
from alembic import command
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
||||||
|
"""
|
||||||
|
Get Alembic configuration for programmatic use.
|
||||||
|
|
||||||
|
Works in both development and installed (Docker) modes by using
|
||||||
|
package location instead of alembic.ini file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file. If None, uses default
|
||||||
|
(/app/data/tokens.db for Docker)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Alembic Config object configured for the specified database
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server import alembic as alembic_package
|
||||||
|
|
||||||
|
# Use package location (works in both editable and installed modes)
|
||||||
|
if alembic_package.__file__ is None:
|
||||||
|
raise RuntimeError("alembic package __file__ is None")
|
||||||
|
script_location = Path(alembic_package.__file__).parent
|
||||||
|
|
||||||
|
# Create config programmatically (no alembic.ini needed at runtime)
|
||||||
|
config = Config()
|
||||||
|
config.set_main_option("script_location", str(script_location))
|
||||||
|
config.set_main_option("path_separator", "os") # Suppress deprecation warning
|
||||||
|
|
||||||
|
# Set database URL
|
||||||
|
if database_path:
|
||||||
|
db_path = Path(database_path).resolve()
|
||||||
|
else:
|
||||||
|
db_path = Path("/app/data/tokens.db") # Default for Docker
|
||||||
|
|
||||||
|
url = f"sqlite+aiosqlite:///{db_path}"
|
||||||
|
config.set_main_option("sqlalchemy.url", url)
|
||||||
|
|
||||||
|
logger.debug(f"Alembic script location: {script_location}")
|
||||||
|
logger.debug(f"Database: {db_path}")
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade_database(
|
||||||
|
database_path: str | Path | None = None, revision: str = "head"
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Upgrade database to a specific revision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file
|
||||||
|
revision: Target revision (default: "head" for latest)
|
||||||
|
"""
|
||||||
|
config = get_alembic_config(database_path)
|
||||||
|
logger.info(f"Upgrading database to revision: {revision}")
|
||||||
|
command.upgrade(config, revision)
|
||||||
|
logger.info("Database upgrade completed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade_database(
|
||||||
|
database_path: str | Path | None = None, revision: str = "-1"
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Downgrade database to a specific revision.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file
|
||||||
|
revision: Target revision (default: "-1" for previous version)
|
||||||
|
"""
|
||||||
|
config = get_alembic_config(database_path)
|
||||||
|
logger.warning(f"Downgrading database to revision: {revision}")
|
||||||
|
command.downgrade(config, revision)
|
||||||
|
logger.info("Database downgrade completed successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_revision(database_path: str | Path | None = None) -> str | None:
|
||||||
|
"""
|
||||||
|
Get the current database revision by directly querying the alembic_version table.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Current revision ID or None if not versioned
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if database_path is None:
|
||||||
|
database_path = "/app/data/tokens.db"
|
||||||
|
|
||||||
|
db_path = Path(database_path).resolve()
|
||||||
|
|
||||||
|
if not db_path.exists():
|
||||||
|
logger.debug(f"Database does not exist: {db_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Query alembic_version table directly
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if alembic_version table exists
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
|
||||||
|
)
|
||||||
|
has_table = cursor.fetchone() is not None
|
||||||
|
|
||||||
|
if not has_table:
|
||||||
|
conn.close()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get current version
|
||||||
|
cursor.execute("SELECT version_num FROM alembic_version")
|
||||||
|
row = cursor.fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return row[0] if row else None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get current revision: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def stamp_database(
|
||||||
|
database_path: str | Path | None = None, revision: str = "head"
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Stamp database with a specific revision without running migrations.
|
||||||
|
|
||||||
|
This is useful for marking existing databases that were created before
|
||||||
|
Alembic was introduced. It tells Alembic "this database is at revision X"
|
||||||
|
without actually running the migration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file
|
||||||
|
revision: Revision to stamp (default: "head" for latest)
|
||||||
|
"""
|
||||||
|
config = get_alembic_config(database_path)
|
||||||
|
logger.info(f"Stamping database with revision: {revision}")
|
||||||
|
command.stamp(config, revision)
|
||||||
|
logger.info("Database stamped successfully")
|
||||||
|
|
||||||
|
|
||||||
|
def show_migration_history(database_path: str | Path | None = None) -> None:
|
||||||
|
"""
|
||||||
|
Display migration history.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
database_path: Path to SQLite database file
|
||||||
|
"""
|
||||||
|
config = get_alembic_config(database_path)
|
||||||
|
command.history(config, verbose=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_migration(message: str, autogenerate: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Create a new migration script.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
message: Description of the migration
|
||||||
|
autogenerate: Whether to attempt auto-generation (requires SQLAlchemy models)
|
||||||
|
|
||||||
|
Note:
|
||||||
|
Since we don't use SQLAlchemy models, autogenerate will be disabled
|
||||||
|
and migrations must be written manually.
|
||||||
|
"""
|
||||||
|
config = get_alembic_config()
|
||||||
|
logger.info(f"Creating new migration: {message}")
|
||||||
|
|
||||||
|
if autogenerate:
|
||||||
|
logger.warning(
|
||||||
|
"Auto-generation is not supported (no SQLAlchemy models). "
|
||||||
|
"Migration will be created with empty upgrade/downgrade functions."
|
||||||
|
)
|
||||||
|
|
||||||
|
command.revision(config, message=message, autogenerate=False)
|
||||||
|
logger.info("Migration created successfully. Edit the file to add SQL statements.")
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Pydantic models for Nextcloud News app responses."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .base import BaseResponse
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFolder(BaseModel):
|
||||||
|
"""Model for a News folder."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Folder ID")
|
||||||
|
name: str = Field(description="Folder name")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFeed(BaseModel):
|
||||||
|
"""Model for a News feed (RSS/Atom subscription)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Feed ID")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
favicon_link: str | None = Field(
|
||||||
|
None, alias="faviconLink", description="Favicon URL"
|
||||||
|
)
|
||||||
|
link: str | None = Field(None, description="Website link")
|
||||||
|
added: int = Field(description="Unix timestamp when feed was added")
|
||||||
|
folder_id: int | None = Field(
|
||||||
|
None, alias="folderId", description="Parent folder ID"
|
||||||
|
)
|
||||||
|
unread_count: int = Field(
|
||||||
|
0, alias="unreadCount", description="Number of unread items"
|
||||||
|
)
|
||||||
|
ordering: int = Field(
|
||||||
|
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
|
||||||
|
)
|
||||||
|
pinned: bool = Field(False, description="Whether feed is pinned to top")
|
||||||
|
update_error_count: int = Field(
|
||||||
|
0, alias="updateErrorCount", description="Consecutive update failures"
|
||||||
|
)
|
||||||
|
last_update_error: str | None = Field(
|
||||||
|
None, alias="lastUpdateError", description="Last update error message"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
"""Check if feed has update errors."""
|
||||||
|
return self.update_error_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItem(BaseModel):
|
||||||
|
"""Model for a News item (article) with full content."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
guid: str = Field(description="Globally unique identifier")
|
||||||
|
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
body: str | None = Field(None, description="Article content (HTML)")
|
||||||
|
enclosure_mime: str | None = Field(
|
||||||
|
None, alias="enclosureMime", description="Enclosure MIME type"
|
||||||
|
)
|
||||||
|
enclosure_link: str | None = Field(
|
||||||
|
None, alias="enclosureLink", description="Enclosure URL"
|
||||||
|
)
|
||||||
|
media_thumbnail: str | None = Field(
|
||||||
|
None, alias="mediaThumbnail", description="Media thumbnail URL"
|
||||||
|
)
|
||||||
|
media_description: str | None = Field(
|
||||||
|
None, alias="mediaDescription", description="Media description"
|
||||||
|
)
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
rtl: bool = Field(False, description="Right-to-left text")
|
||||||
|
last_modified: int = Field(
|
||||||
|
alias="lastModified", description="Last modification timestamp"
|
||||||
|
)
|
||||||
|
fingerprint: str | None = Field(
|
||||||
|
None, description="Content fingerprint for deduplication"
|
||||||
|
)
|
||||||
|
content_hash: str | None = Field(
|
||||||
|
None, alias="contentHash", description="Content hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemSummary(BaseModel):
|
||||||
|
"""Lightweight model for News item list responses."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsStatus(BaseModel):
|
||||||
|
"""Model for News app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Response Models ---
|
||||||
|
|
||||||
|
|
||||||
|
class ListFoldersResponse(BaseResponse):
|
||||||
|
"""Response model for listing folders."""
|
||||||
|
|
||||||
|
results: List[NewsFolder] = Field(description="List of folders")
|
||||||
|
total_count: int = Field(description="Total number of folders")
|
||||||
|
|
||||||
|
|
||||||
|
class ListFeedsResponse(BaseResponse):
|
||||||
|
"""Response model for listing feeds."""
|
||||||
|
|
||||||
|
results: List[NewsFeed] = Field(description="List of feeds")
|
||||||
|
starred_count: int = Field(0, description="Number of starred items")
|
||||||
|
newest_item_id: int | None = Field(None, description="ID of newest item")
|
||||||
|
total_count: int = Field(description="Total number of feeds")
|
||||||
|
|
||||||
|
|
||||||
|
class ListItemsResponse(BaseResponse):
|
||||||
|
"""Response model for listing items."""
|
||||||
|
|
||||||
|
results: List[NewsItemSummary] = Field(description="List of items")
|
||||||
|
total_count: int = Field(description="Number of items returned")
|
||||||
|
has_more: bool = Field(False, description="Whether more items exist")
|
||||||
|
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
|
||||||
|
|
||||||
|
|
||||||
|
class GetItemResponse(BaseResponse):
|
||||||
|
"""Response model for getting a single item."""
|
||||||
|
|
||||||
|
item: NewsItem = Field(description="Full item details")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedHealthResponse(BaseResponse):
|
||||||
|
"""Response model for feed health status."""
|
||||||
|
|
||||||
|
feed_id: int = Field(description="Feed ID")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
has_errors: bool = Field(description="Whether feed has update errors")
|
||||||
|
error_count: int = Field(description="Number of consecutive errors")
|
||||||
|
last_error: str | None = Field(None, description="Last error message")
|
||||||
|
|
||||||
|
|
||||||
|
class GetStatusResponse(BaseResponse):
|
||||||
|
"""Response model for app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
@@ -38,6 +38,9 @@ class SemanticSearchResult(BaseModel):
|
|||||||
page_number: Optional[int] = Field(
|
page_number: Optional[int] = Field(
|
||||||
default=None, description="Page number for PDF documents"
|
default=None, description="Page number for PDF documents"
|
||||||
)
|
)
|
||||||
|
page_count: Optional[int] = Field(
|
||||||
|
default=None, description="Total number of pages in PDF document"
|
||||||
|
)
|
||||||
# Context expansion fields (optional, populated when include_context=True)
|
# Context expansion fields (optional, populated when include_context=True)
|
||||||
has_context_expansion: bool = Field(
|
has_context_expansion: bool = Field(
|
||||||
default=False, description="Whether context expansion was performed"
|
default=False, description="Whether context expansion was performed"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
|
|
||||||
def add_fields(
|
def add_fields(
|
||||||
self,
|
self,
|
||||||
log_record: dict[str, Any],
|
log_data: dict[str, Any],
|
||||||
record: logging.LogRecord,
|
record: logging.LogRecord,
|
||||||
message_dict: dict[str, Any],
|
message_dict: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
Add custom fields to the log record, including trace context.
|
Add custom fields to the log record, including trace context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_record: Dictionary to be serialized as JSON
|
log_data: Dictionary to be serialized as JSON
|
||||||
record: LogRecord instance
|
record: LogRecord instance
|
||||||
message_dict: Dictionary of extra fields from log call
|
message_dict: Dictionary of extra fields from log call
|
||||||
"""
|
"""
|
||||||
# Call parent to add standard fields
|
# Call parent to add standard fields
|
||||||
super().add_fields(log_record, record, message_dict)
|
super().add_fields(log_data, record, message_dict)
|
||||||
|
|
||||||
# Add trace context if available
|
# Add trace context if available
|
||||||
trace_context = get_trace_context()
|
trace_context = get_trace_context()
|
||||||
if trace_context:
|
if trace_context:
|
||||||
log_record["trace_id"] = trace_context.get("trace_id")
|
log_data["trace_id"] = trace_context.get("trace_id")
|
||||||
log_record["span_id"] = trace_context.get("span_id")
|
log_data["span_id"] = trace_context.get("span_id")
|
||||||
|
|
||||||
# Add standard fields with consistent naming
|
# Add standard fields with consistent naming
|
||||||
log_record["timestamp"] = self.formatTime(record)
|
log_data["timestamp"] = self.formatTime(record)
|
||||||
log_record["level"] = record.levelname
|
log_data["level"] = record.levelname
|
||||||
log_record["logger"] = record.name
|
log_data["logger"] = record.name
|
||||||
log_record["message"] = record.getMessage()
|
log_data["message"] = record.getMessage()
|
||||||
|
|
||||||
# Include exception info if present
|
# Include exception info if present
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
log_record["exception"] = self.formatException(record.exc_info)
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
|
||||||
class TraceContextTextFormatter(logging.Formatter):
|
class TraceContextTextFormatter(logging.Formatter):
|
||||||
|
|||||||
@@ -53,10 +53,11 @@ def setup_tracing(
|
|||||||
global _tracer
|
global _tracer
|
||||||
|
|
||||||
# Create resource with service name
|
# Create resource with service name
|
||||||
|
pkg_name = __package__.split(".")[0] if __package__ else "nextcloud_mcp_server"
|
||||||
resource = Resource.create(
|
resource = Resource.create(
|
||||||
{
|
{
|
||||||
"service.name": service_name,
|
"service.name": service_name,
|
||||||
"service.version": version(__package__.split(".")[0]),
|
"service.version": version(pkg_name),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
|||||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
Note: Anthropic doesn't provide embedding models, only text generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
|
def __init__(
|
||||||
|
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize Anthropic provider.
|
Initialize Anthropic provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Anthropic API key
|
api_key: Anthropic API key
|
||||||
model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||||
"""
|
"""
|
||||||
self.client = AsyncAnthropic(api_key=api_key)
|
self.client = AsyncAnthropic(api_key=api_key)
|
||||||
self.model = model
|
self.model = generation_model
|
||||||
|
|
||||||
logger.info(f"Initialized Anthropic provider (model={model})")
|
logger.info(f"Initialized Anthropic provider (model={self.model})")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_embeddings(self) -> bool:
|
def supports_embeddings(self) -> bool:
|
||||||
|
|||||||
@@ -7,13 +7,48 @@ Supports:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
import anyio
|
||||||
|
from openai import AsyncOpenAI, RateLimitError
|
||||||
|
|
||||||
from .base import Provider
|
from .base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rate limit retry configuration
|
||||||
|
MAX_RETRIES = 5
|
||||||
|
INITIAL_RETRY_DELAY = 2.0 # seconds
|
||||||
|
MAX_RETRY_DELAY = 60.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
def retry_on_rate_limit(func):
|
||||||
|
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
async def wrapper(*args, **kwargs):
|
||||||
|
retry_delay = INITIAL_RETRY_DELAY
|
||||||
|
last_error: Exception | None = None
|
||||||
|
|
||||||
|
for attempt in range(1, MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
|
return await func(*args, **kwargs)
|
||||||
|
except RateLimitError as e:
|
||||||
|
last_error = e
|
||||||
|
if attempt < MAX_RETRIES:
|
||||||
|
logger.warning(
|
||||||
|
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
|
||||||
|
f"retrying in {retry_delay:.1f}s..."
|
||||||
|
)
|
||||||
|
await anyio.sleep(retry_delay)
|
||||||
|
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||||
|
|
||||||
|
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
|
||||||
|
raise last_error # type: ignore[misc]
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
# Well-known embedding dimensions for OpenAI models
|
# Well-known embedding dimensions for OpenAI models
|
||||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||||
"text-embedding-3-small": 1536,
|
"text-embedding-3-small": 1536,
|
||||||
@@ -86,6 +121,7 @@ class OpenAIProvider(Provider):
|
|||||||
"""Whether this provider supports text generation."""
|
"""Whether this provider supports text generation."""
|
||||||
return self.generation_model is not None
|
return self.generation_model is not None
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Generate embedding vector for text.
|
Generate embedding vector for text.
|
||||||
@@ -104,6 +140,7 @@ class OpenAIProvider(Provider):
|
|||||||
"Embedding not supported - no embedding_model configured"
|
"Embedding not supported - no embedding_model configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.embedding_model,
|
model=self.embedding_model,
|
||||||
@@ -151,14 +188,8 @@ class OpenAIProvider(Provider):
|
|||||||
for i in range(0, len(texts), batch_size):
|
for i in range(0, len(texts), batch_size):
|
||||||
batch = texts[i : i + batch_size]
|
batch = texts[i : i + batch_size]
|
||||||
|
|
||||||
response = await self.client.embeddings.create(
|
# Use helper method with retry logic for each batch
|
||||||
input=batch,
|
batch_embeddings = await self._embed_batch_request(batch)
|
||||||
model=self.embedding_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Sort by index to maintain order
|
|
||||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
|
||||||
batch_embeddings = [item.embedding for item in sorted_data]
|
|
||||||
all_embeddings.extend(batch_embeddings)
|
all_embeddings.extend(batch_embeddings)
|
||||||
|
|
||||||
# Update dimension if not set
|
# Update dimension if not set
|
||||||
@@ -171,6 +202,18 @@ class OpenAIProvider(Provider):
|
|||||||
|
|
||||||
return all_embeddings
|
return all_embeddings
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
|
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||||
|
"""Make a single batch embedding request with retry logic."""
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
|
response = await self.client.embeddings.create(
|
||||||
|
input=batch,
|
||||||
|
model=self.embedding_model,
|
||||||
|
)
|
||||||
|
# Sort by index to maintain order
|
||||||
|
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||||
|
return [item.embedding for item in sorted_data]
|
||||||
|
|
||||||
def get_dimension(self) -> int:
|
def get_dimension(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get embedding dimension.
|
Get embedding dimension.
|
||||||
@@ -194,6 +237,7 @@ class OpenAIProvider(Provider):
|
|||||||
)
|
)
|
||||||
return self._dimension
|
return self._dimension
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||||
"""
|
"""
|
||||||
Generate text from a prompt.
|
Generate text from a prompt.
|
||||||
|
|||||||
@@ -108,10 +108,10 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
|||||||
with_vectors=False, # Don't need vectors for type discovery
|
with_vectors=False, # Don't need vectors for type discovery
|
||||||
)
|
)
|
||||||
|
|
||||||
doc_types = {
|
doc_types: set[str] = {
|
||||||
point.payload.get("doc_type")
|
str(point.payload.get("doc_type"))
|
||||||
for point in scroll_results
|
for point in scroll_results
|
||||||
if point.payload.get("doc_type")
|
if point.payload and point.payload.get("doc_type")
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Found indexed document types for user {user_id}: {doc_types}")
|
logger.debug(f"Found indexed document types for user {user_id}: {doc_types}")
|
||||||
@@ -138,6 +138,7 @@ class SearchResult:
|
|||||||
chunk_start_offset: Character position where chunk starts (None if not available)
|
chunk_start_offset: Character position where chunk starts (None if not available)
|
||||||
chunk_end_offset: Character position where chunk ends (None if not available)
|
chunk_end_offset: Character position where chunk ends (None if not available)
|
||||||
page_number: Page number for PDF documents (None for other doc types)
|
page_number: Page number for PDF documents (None for other doc types)
|
||||||
|
page_count: Total number of pages in PDF document (None for other doc types)
|
||||||
chunk_index: Zero-based index of this chunk in the document
|
chunk_index: Zero-based index of this chunk in the document
|
||||||
total_chunks: Total number of chunks in the document
|
total_chunks: Total number of chunks in the document
|
||||||
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
||||||
@@ -152,6 +153,7 @@ class SearchResult:
|
|||||||
chunk_start_offset: int | None = None
|
chunk_start_offset: int | None = None
|
||||||
chunk_end_offset: int | None = None
|
chunk_end_offset: int | None = None
|
||||||
page_number: int | None = None
|
page_number: int | None = None
|
||||||
|
page_count: int | None = None
|
||||||
chunk_index: int = 0
|
chunk_index: int = 0
|
||||||
total_chunks: int = 1
|
total_chunks: int = 1
|
||||||
point_id: str | None = None
|
point_id: str | None = None
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
for result in search_response.points:
|
||||||
|
if result.payload is None:
|
||||||
|
continue
|
||||||
# doc_id can be int (notes) or str (files - file paths)
|
# doc_id can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
@@ -217,6 +219,22 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
|
|
||||||
seen_chunks.add(chunk_key)
|
seen_chunks.add(chunk_key)
|
||||||
|
|
||||||
|
# Build metadata dict with common fields
|
||||||
|
metadata = {
|
||||||
|
"chunk_index": result.payload.get("chunk_index"),
|
||||||
|
"total_chunks": result.payload.get("total_chunks"),
|
||||||
|
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add file-specific metadata for PDF viewer
|
||||||
|
if doc_type == "file" and (path := result.payload.get("file_path")):
|
||||||
|
metadata["path"] = path
|
||||||
|
|
||||||
|
# Add deck_card-specific metadata for frontend URL construction
|
||||||
|
if doc_type == "deck_card":
|
||||||
|
if board_id := result.payload.get("board_id"):
|
||||||
|
metadata["board_id"] = board_id
|
||||||
|
|
||||||
# Return unverified results (verification happens at output stage)
|
# Return unverified results (verification happens at output stage)
|
||||||
results.append(
|
results.append(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
@@ -225,14 +243,11 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
title=result.payload.get("title", "Untitled"),
|
title=result.payload.get("title", "Untitled"),
|
||||||
excerpt=result.payload.get("excerpt", ""),
|
excerpt=result.payload.get("excerpt", ""),
|
||||||
score=result.score, # Fusion score (RRF or DBSF)
|
score=result.score, # Fusion score (RRF or DBSF)
|
||||||
metadata={
|
metadata=metadata,
|
||||||
"chunk_index": result.payload.get("chunk_index"),
|
|
||||||
"total_chunks": result.payload.get("total_chunks"),
|
|
||||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
|
||||||
},
|
|
||||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||||
page_number=result.payload.get("page_number"),
|
page_number=result.payload.get("page_number"),
|
||||||
|
page_count=result.payload.get("page_count"),
|
||||||
chunk_index=result.payload.get("chunk_index", 0),
|
chunk_index=result.payload.get("chunk_index", 0),
|
||||||
total_chunks=result.payload.get("total_chunks", 1),
|
total_chunks=result.payload.get("total_chunks", 1),
|
||||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||||
|
|||||||
@@ -209,6 +209,64 @@ async def _get_file_path_from_qdrant(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_deck_metadata_from_qdrant(
|
||||||
|
user_id: str, card_id: int
|
||||||
|
) -> dict[str, int] | None:
|
||||||
|
"""Retrieve board_id and stack_id for a deck card from Qdrant payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User ID who owns the card
|
||||||
|
card_id: Card ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with board_id and stack_id, or None if not found
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Query for any chunk of this card (we just need metadata)
|
||||||
|
scroll_result = await qdrant_client.scroll(
|
||||||
|
collection_name=settings.get_collection_name(),
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||||
|
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
|
||||||
|
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
limit=1,
|
||||||
|
with_payload=["board_id", "stack_id"],
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
if scroll_result[0]:
|
||||||
|
point = scroll_result[0][0]
|
||||||
|
board_id = point.payload.get("board_id")
|
||||||
|
stack_id = point.payload.get("stack_id")
|
||||||
|
if board_id is not None and stack_id is not None:
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved deck metadata for card {card_id}: "
|
||||||
|
f"board_id={board_id}, stack_id={stack_id}"
|
||||||
|
)
|
||||||
|
return {"board_id": int(board_id), "stack_id": int(stack_id)}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Could not find deck metadata in Qdrant for card {card_id} "
|
||||||
|
f"(might be legacy data without board_id/stack_id)"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Error querying Qdrant for deck metadata: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ChunkContext:
|
class ChunkContext:
|
||||||
"""Expanded chunk with surrounding context and position markers.
|
"""Expanded chunk with surrounding context and position markers.
|
||||||
@@ -394,7 +452,9 @@ async def get_chunk_with_context(
|
|||||||
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
||||||
|
|
||||||
# Fetch full document text
|
# Fetch full document text
|
||||||
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
|
full_text = await _fetch_document_text(
|
||||||
|
nc_client, resolved_doc_id, doc_type, user_id
|
||||||
|
)
|
||||||
if full_text is None:
|
if full_text is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not fetch document text for {doc_type} {doc_id}, "
|
f"Could not fetch document text for {doc_type} {doc_id}, "
|
||||||
@@ -453,7 +513,7 @@ async def get_chunk_with_context(
|
|||||||
|
|
||||||
|
|
||||||
async def _fetch_document_text(
|
async def _fetch_document_text(
|
||||||
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
|
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Fetch full text content of a document.
|
"""Fetch full text content of a document.
|
||||||
|
|
||||||
@@ -524,6 +584,96 @@ async def _fetch_document_text(
|
|||||||
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
elif doc_type == "news_item":
|
||||||
|
# Fetch news item by ID
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
|
|
||||||
|
item = await nc_client.news.get_item(int(doc_id))
|
||||||
|
# Reconstruct full content as indexed: title + source + URL + body
|
||||||
|
# This ensures chunk offsets align with indexed content structure
|
||||||
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
|
item_title = item.get("title", "")
|
||||||
|
item_url = item.get("url", "")
|
||||||
|
feed_title = item.get("feedTitle", "")
|
||||||
|
|
||||||
|
content_parts = [item_title]
|
||||||
|
if feed_title:
|
||||||
|
content_parts.append(f"Source: {feed_title}")
|
||||||
|
if item_url:
|
||||||
|
content_parts.append(f"URL: {item_url}")
|
||||||
|
content_parts.append("") # Blank line
|
||||||
|
content_parts.append(body_markdown)
|
||||||
|
return "\n".join(content_parts)
|
||||||
|
elif doc_type == "deck_card":
|
||||||
|
# Fetch card from Deck API
|
||||||
|
# Try to get board_id/stack_id from Qdrant metadata (O(1) lookup)
|
||||||
|
# Otherwise fall back to iteration (legacy data)
|
||||||
|
card = None
|
||||||
|
deck_metadata = await _get_deck_metadata_from_qdrant(user_id, int(doc_id))
|
||||||
|
|
||||||
|
if deck_metadata:
|
||||||
|
# Fast path: Direct lookup with known board_id/stack_id
|
||||||
|
board_id = deck_metadata["board_id"]
|
||||||
|
stack_id = deck_metadata["stack_id"]
|
||||||
|
try:
|
||||||
|
card = await nc_client.deck.get_card(
|
||||||
|
board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Retrieved deck card {doc_id} using metadata "
|
||||||
|
f"(board_id={board_id}, stack_id={stack_id})"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to fetch card with metadata (board_id={board_id}, "
|
||||||
|
f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
|
||||||
|
if card is None:
|
||||||
|
boards = await nc_client.deck.get_boards()
|
||||||
|
card_found = False
|
||||||
|
|
||||||
|
for board in boards:
|
||||||
|
if card_found:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||||
|
if board.deletedAt > 0:
|
||||||
|
logger.debug(
|
||||||
|
f"Skipping deleted board {board.id} while searching for card {doc_id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
stacks = await nc_client.deck.get_stacks(board.id)
|
||||||
|
|
||||||
|
for stack in stacks:
|
||||||
|
if card_found:
|
||||||
|
break
|
||||||
|
if stack.cards:
|
||||||
|
for c in stack.cards:
|
||||||
|
if c.id == int(doc_id):
|
||||||
|
card = c
|
||||||
|
card_found = True
|
||||||
|
logger.debug(
|
||||||
|
f"Found deck card {doc_id} in board {board.id}, "
|
||||||
|
f"stack {stack.id} (fallback iteration)"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not card_found:
|
||||||
|
logger.warning(f"Deck card {doc_id} not found in any board/stack")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Type narrowing: card is set if we reach here
|
||||||
|
assert card is not None
|
||||||
|
|
||||||
|
# Reconstruct full content as indexed: title + "\n\n" + description
|
||||||
|
# This ensures chunk offsets align with indexed content structure
|
||||||
|
content_parts = [card.title]
|
||||||
|
if card.description:
|
||||||
|
content_parts.append(card.description)
|
||||||
|
return "\n\n".join(content_parts)
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
for result in search_response.points:
|
||||||
|
if result.payload is None:
|
||||||
|
continue
|
||||||
# doc_id can be int (notes) or str (files - file paths)
|
# doc_id can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
@@ -149,6 +151,21 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
|
|
||||||
seen_chunks.add(chunk_key)
|
seen_chunks.add(chunk_key)
|
||||||
|
|
||||||
|
# Build metadata dict with common fields
|
||||||
|
metadata = {
|
||||||
|
"chunk_index": result.payload.get("chunk_index"),
|
||||||
|
"total_chunks": result.payload.get("total_chunks"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Add file-specific metadata for PDF viewer
|
||||||
|
if doc_type == "file" and (path := result.payload.get("file_path")):
|
||||||
|
metadata["path"] = path
|
||||||
|
|
||||||
|
# Add deck_card-specific metadata for frontend URL construction
|
||||||
|
if doc_type == "deck_card":
|
||||||
|
if board_id := result.payload.get("board_id"):
|
||||||
|
metadata["board_id"] = board_id
|
||||||
|
|
||||||
# Return unverified results (verification happens at output stage)
|
# Return unverified results (verification happens at output stage)
|
||||||
results.append(
|
results.append(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
@@ -157,13 +174,11 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
title=result.payload.get("title", "Untitled"),
|
title=result.payload.get("title", "Untitled"),
|
||||||
excerpt=result.payload.get("excerpt", ""),
|
excerpt=result.payload.get("excerpt", ""),
|
||||||
score=result.score,
|
score=result.score,
|
||||||
metadata={
|
metadata=metadata,
|
||||||
"chunk_index": result.payload.get("chunk_index"),
|
|
||||||
"total_chunks": result.payload.get("total_chunks"),
|
|
||||||
},
|
|
||||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||||
page_number=result.payload.get("page_number"),
|
page_number=result.payload.get("page_number"),
|
||||||
|
page_count=result.payload.get("page_count"),
|
||||||
chunk_index=result.payload.get("chunk_index", 0),
|
chunk_index=result.payload.get("chunk_index", 0),
|
||||||
total_chunks=result.payload.get("total_chunks", 1),
|
total_chunks=result.payload.get("total_chunks", 1),
|
||||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
|||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
from .cookbook import configure_cookbook_tools
|
from .cookbook import configure_cookbook_tools
|
||||||
from .deck import configure_deck_tools
|
from .deck import configure_deck_tools
|
||||||
|
from .news import configure_news_tools
|
||||||
from .notes import configure_notes_tools
|
from .notes import configure_notes_tools
|
||||||
from .semantic import configure_semantic_tools
|
from .semantic import configure_semantic_tools
|
||||||
from .sharing import configure_sharing_tools
|
from .sharing import configure_sharing_tools
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"configure_contacts_tools",
|
"configure_contacts_tools",
|
||||||
"configure_cookbook_tools",
|
"configure_cookbook_tools",
|
||||||
"configure_deck_tools",
|
"configure_deck_tools",
|
||||||
|
"configure_news_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_semantic_tools",
|
"configure_semantic_tools",
|
||||||
"configure_sharing_tools",
|
"configure_sharing_tools",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -19,7 +20,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_calendar_tools(mcp: FastMCP):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Calendars",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
@@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Calendar Event",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_event(
|
async def nc_calendar_create_event(
|
||||||
@@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Calendar Events",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_events(
|
async def nc_calendar_list_events(
|
||||||
@@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Calendar Event",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_event(
|
async def nc_calendar_get_event(
|
||||||
@@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Calendar Event",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_event(
|
async def nc_calendar_update_event(
|
||||||
@@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
calendar_name, event_uid, event_data, etag
|
calendar_name, event_uid, event_data, etag
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Calendar Event",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_event(
|
async def nc_calendar_delete_event(
|
||||||
@@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Meeting",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_meeting(
|
async def nc_calendar_create_meeting(
|
||||||
@@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Upcoming Events",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_upcoming_events(
|
async def nc_calendar_get_upcoming_events(
|
||||||
@@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||||
return all_events[:limit]
|
return all_events[:limit]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Availability",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_find_availability(
|
async def nc_calendar_find_availability(
|
||||||
@@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
constraints=constraints,
|
constraints=constraints,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Bulk Calendar Operations",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_bulk_operations(
|
async def nc_calendar_bulk_operations(
|
||||||
@@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
"results": results,
|
"results": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Manage Calendar",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_manage_calendar(
|
async def nc_calendar_manage_calendar(
|
||||||
@@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# ============= Todo/Task Tools =============
|
# ============= Todo/Task Tools =============
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Todo Tasks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_todos(
|
async def nc_calendar_list_todos(
|
||||||
@@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Todo Task",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_todo(
|
async def nc_calendar_create_todo(
|
||||||
@@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Todo Task",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_todo(
|
async def nc_calendar_update_todo(
|
||||||
@@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Todo Task",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_todo(
|
async def nc_calendar_delete_todo(
|
||||||
@@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Todo Tasks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_search_todos(
|
async def nc_calendar_search_todos(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Address Books",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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):
|
||||||
@@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
return await client.contacts.list_addressbooks()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Contacts",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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):
|
||||||
@@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Address Book",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_addressbook(
|
async def nc_contacts_create_addressbook(
|
||||||
@@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
name=name, display_name=display_name
|
name=name, display_name=display_name
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Address Book",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
@@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.delete_addressbook(name=name)
|
return await client.contacts.delete_addressbook(name=name)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Contact",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_contact(
|
async def nc_contacts_create_contact(
|
||||||
@@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Contact",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
@@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Contact",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_update_contact(
|
async def nc_contacts_update_contact(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, 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 ErrorData
|
from mcp.types import ErrorData, 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
|
||||||
@@ -71,7 +71,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Import Recipe from URL",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||||
@@ -129,7 +132,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipes",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||||
@@ -155,7 +161,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipe",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||||
@@ -181,7 +190,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Recipe",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_create_recipe(
|
async def nc_cookbook_create_recipe(
|
||||||
@@ -261,7 +273,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Recipe",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_update_recipe(
|
async def nc_cookbook_update_recipe(
|
||||||
@@ -351,7 +366,12 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Recipe",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_delete_recipe(
|
async def nc_cookbook_delete_recipe(
|
||||||
@@ -387,7 +407,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Recipes",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_search_recipes(
|
async def nc_cookbook_search_recipes(
|
||||||
@@ -424,7 +447,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipe Categories",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||||
@@ -452,7 +478,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipes in Category",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_in_category(
|
async def nc_cookbook_get_recipes_in_category(
|
||||||
@@ -489,7 +518,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipe Keywords",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||||
@@ -515,7 +547,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipes with Keywords",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_with_keywords(
|
async def nc_cookbook_get_recipes_with_keywords(
|
||||||
@@ -550,7 +585,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Set Cookbook Configuration",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_set_config(
|
async def nc_cookbook_set_config(
|
||||||
@@ -594,7 +632,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Reindex Recipes",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -117,7 +118,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Read Tools (converted from resources)
|
# Read Tools (converted from resources)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Boards",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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) -> list[DeckBoard]:
|
||||||
@@ -126,7 +130,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return boards
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Board",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
@@ -135,7 +142,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board
|
return board
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Stacks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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) -> list[DeckStack]:
|
||||||
@@ -144,7 +154,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Stack",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
@@ -153,7 +166,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
return stack
|
return stack
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Cards",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
@@ -166,7 +182,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stack.cards
|
return stack.cards
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Card",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_card(
|
async def deck_get_card(
|
||||||
@@ -177,7 +196,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Labels",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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) -> list[DeckLabel]:
|
||||||
@@ -186,7 +208,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
return board.labels
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Label",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
@@ -197,7 +222,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Create/Update/Delete Tools
|
# Create/Update/Delete Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Board",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_board(
|
async def deck_create_board(
|
||||||
@@ -215,7 +243,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Stack Tools
|
# Stack Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Stack",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_stack(
|
async def deck_create_stack(
|
||||||
@@ -232,7 +263,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack = await client.deck.create_stack(board_id, title, order)
|
stack = await client.deck.create_stack(board_id, title, order)
|
||||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Stack",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_stack(
|
async def deck_update_stack(
|
||||||
@@ -259,7 +293,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Stack",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_stack(
|
async def deck_delete_stack(
|
||||||
@@ -281,7 +320,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card Tools
|
# Card Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_card(
|
async def deck_create_card(
|
||||||
@@ -316,7 +358,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stackId=card.stackId,
|
stackId=card.stackId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_card(
|
async def deck_update_card(
|
||||||
@@ -370,7 +415,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Card",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_card(
|
async def deck_delete_card(
|
||||||
@@ -393,7 +443,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Archive Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_archive_card(
|
async def deck_archive_card(
|
||||||
@@ -416,7 +469,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Unarchive Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unarchive_card(
|
async def deck_unarchive_card(
|
||||||
@@ -439,7 +495,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Reorder/Move Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_reorder_card(
|
async def deck_reorder_card(
|
||||||
@@ -472,7 +531,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Label Tools
|
# Label Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Label",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_label(
|
async def deck_create_label(
|
||||||
@@ -489,7 +551,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
label = await client.deck.create_label(board_id, title, color)
|
label = await client.deck.create_label(board_id, title, color)
|
||||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Label",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_label(
|
async def deck_update_label(
|
||||||
@@ -516,7 +581,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Label",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_label(
|
async def deck_delete_label(
|
||||||
@@ -538,7 +608,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card-Label Assignment Tools
|
# Card-Label Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Assign Label to Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_label_to_card(
|
async def deck_assign_label_to_card(
|
||||||
@@ -562,7 +635,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Remove Label from Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_remove_label_from_card(
|
async def deck_remove_label_from_card(
|
||||||
@@ -587,7 +663,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card-User Assignment Tools
|
# Card-User Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Assign User to Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_user_to_card(
|
async def deck_assign_user_to_card(
|
||||||
@@ -611,7 +690,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Unassign User from Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unassign_user_from_card(
|
async def deck_unassign_user_from_card(
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""MCP tools for Nextcloud News app."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.shared.exceptions import McpError
|
||||||
|
from mcp.types import ErrorData, ToolAnnotations
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
|
from nextcloud_mcp_server.context import get_client
|
||||||
|
from nextcloud_mcp_server.models.news import (
|
||||||
|
FeedHealthResponse,
|
||||||
|
GetItemResponse,
|
||||||
|
GetStatusResponse,
|
||||||
|
ListFeedsResponse,
|
||||||
|
ListFoldersResponse,
|
||||||
|
ListItemsResponse,
|
||||||
|
NewsFeed,
|
||||||
|
NewsFolder,
|
||||||
|
NewsItem,
|
||||||
|
NewsItemSummary,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_news_tools(mcp: FastMCP):
|
||||||
|
"""Configure News app MCP tools."""
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="List News Folders",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
|
||||||
|
"""List all News folders (requires news:read scope)."""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
folders_data = await client.news.get_folders()
|
||||||
|
folders = [NewsFolder(**f) for f in folders_data]
|
||||||
|
return ListFoldersResponse(results=folders, total_count=len(folders))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list folders: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="List News Feeds",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
|
||||||
|
"""List all News feeds with metadata (requires news:read scope).
|
||||||
|
|
||||||
|
Returns feeds with unread counts, error status, and overall starred count.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
data = await client.news.get_feeds()
|
||||||
|
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
|
||||||
|
return ListFeedsResponse(
|
||||||
|
results=feeds,
|
||||||
|
starred_count=data.get("starredCount", 0),
|
||||||
|
newest_item_id=data.get("newestItemId"),
|
||||||
|
total_count=len(feeds),
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list feeds: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="List News Items",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_list_items(
|
||||||
|
ctx: Context,
|
||||||
|
feed_id: int | None = None,
|
||||||
|
folder_id: int | None = None,
|
||||||
|
starred_only: bool = False,
|
||||||
|
unread_only: bool = False,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""List News items (articles) with optional filtering (requires news:read scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Filter by specific feed ID
|
||||||
|
folder_id: Filter by specific folder ID
|
||||||
|
starred_only: Return only starred items
|
||||||
|
unread_only: Return only unread items
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with items, count, and pagination info
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
|
||||||
|
# Determine item type filter
|
||||||
|
type_ = NewsItemType.ALL
|
||||||
|
id_ = 0
|
||||||
|
if starred_only:
|
||||||
|
type_ = NewsItemType.STARRED
|
||||||
|
elif feed_id is not None:
|
||||||
|
type_ = NewsItemType.FEED
|
||||||
|
id_ = feed_id
|
||||||
|
elif folder_id is not None:
|
||||||
|
type_ = NewsItemType.FOLDER
|
||||||
|
id_ = folder_id
|
||||||
|
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=type_,
|
||||||
|
id_=id_,
|
||||||
|
get_read=not unread_only,
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
# Determine pagination info
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to list items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get News Item",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
|
||||||
|
"""Get a specific News item by ID with full content (requires news:read scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GetItemResponse with full item details including HTML body
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
item_data = await client.news.get_item(item_id)
|
||||||
|
item = NewsItem(**item_data)
|
||||||
|
return GetItemResponse(item=item)
|
||||||
|
except ValueError as e:
|
||||||
|
raise McpError(ErrorData(code=-1, message=str(e)))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get item {item_id}: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get Starred News Items",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_starred_items(
|
||||||
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""Get starred (favorited) News items (requires news:read scope).
|
||||||
|
|
||||||
|
Convenience method for retrieving user's starred articles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with starred items
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=NewsItemType.STARRED,
|
||||||
|
get_read=True, # Include read starred items
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting starred items: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get starred items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get Unread News Items",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_unread_items(
|
||||||
|
ctx: Context, limit: int = 50, offset: int = 0
|
||||||
|
) -> ListItemsResponse:
|
||||||
|
"""Get unread News items (requires news:read scope).
|
||||||
|
|
||||||
|
Convenience method for retrieving unread articles across all feeds.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
limit: Maximum number of items to return (default 50, -1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ListItemsResponse with unread items
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
items_data = await client.news.get_items(
|
||||||
|
batch_size=limit,
|
||||||
|
offset=offset,
|
||||||
|
type_=NewsItemType.ALL,
|
||||||
|
get_read=False, # Only unread items
|
||||||
|
)
|
||||||
|
items = [NewsItemSummary(**i) for i in items_data]
|
||||||
|
|
||||||
|
oldest_id = min((i.id for i in items), default=None) if items else None
|
||||||
|
has_more = len(items) == limit and limit > 0
|
||||||
|
|
||||||
|
return ListItemsResponse(
|
||||||
|
results=items,
|
||||||
|
total_count=len(items),
|
||||||
|
has_more=has_more,
|
||||||
|
oldest_id=oldest_id,
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1, message=f"Network error getting unread items: {str(e)}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get unread items: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get News Feed Health",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
|
||||||
|
"""Get health status for a specific feed (requires news:read scope).
|
||||||
|
|
||||||
|
Returns error count and last error message if the feed has update issues.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
FeedHealthResponse with error status
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
data = await client.news.get_feeds()
|
||||||
|
for feed_data in data.get("feeds", []):
|
||||||
|
if feed_data.get("id") == feed_id:
|
||||||
|
feed = NewsFeed(**feed_data)
|
||||||
|
return FeedHealthResponse(
|
||||||
|
feed_id=feed.id,
|
||||||
|
title=feed.title,
|
||||||
|
url=feed.url,
|
||||||
|
has_errors=feed.has_errors,
|
||||||
|
error_count=feed.update_error_count,
|
||||||
|
last_error=feed.last_update_error,
|
||||||
|
)
|
||||||
|
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Network error getting feed health: {str(e)}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get feed health: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Get News App Status",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
|
@require_scopes("news:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
|
||||||
|
"""Get News app status and version (requires news:read scope).
|
||||||
|
|
||||||
|
Returns version information and any configuration warnings.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
try:
|
||||||
|
status_data = await client.news.get_status()
|
||||||
|
return GetStatusResponse(
|
||||||
|
version=status_data.get("version", "unknown"),
|
||||||
|
warnings=status_data.get("warnings", {}),
|
||||||
|
)
|
||||||
|
except RequestError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
|
||||||
|
)
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
raise McpError(
|
||||||
|
ErrorData(
|
||||||
|
code=-1,
|
||||||
|
message=f"Failed to get status: {e.response.status_code}",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, 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 ErrorData
|
from mcp.types import ErrorData, 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
|
||||||
@@ -85,7 +85,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Multiple calls create multiple notes
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
@@ -132,7 +138,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Requires etag which changes = not idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_update_note(
|
async def nc_notes_update_note(
|
||||||
@@ -198,7 +210,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Append to Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Each call adds content = not idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
@@ -249,7 +267,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
@@ -296,7 +320,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Read operation only
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
@@ -326,7 +356,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Note Attachment",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Read operation only
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_attachment(
|
async def nc_notes_get_attachment(
|
||||||
@@ -373,7 +409,14 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Permanently deletes data
|
||||||
|
idempotentHint=True, # Deleting deleted note = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import httpx
|
|||||||
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
|
||||||
|
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
|
||||||
@@ -100,6 +101,9 @@ class ProvisioningStatus(BaseModel):
|
|||||||
provisioned_at: Optional[str] = Field(
|
provisioned_at: Optional[str] = Field(
|
||||||
None, description="ISO timestamp when provisioned"
|
None, description="ISO timestamp when provisioned"
|
||||||
)
|
)
|
||||||
|
credential_type: Optional[str] = Field(
|
||||||
|
None, description="Type of credential ('refresh_token' or 'app_password')"
|
||||||
|
)
|
||||||
client_id: Optional[str] = Field(
|
client_id: Optional[str] = Field(
|
||||||
None, description="Client ID that initiated the original Flow 1"
|
None, description="Client ID that initiated the original Flow 1"
|
||||||
)
|
)
|
||||||
@@ -113,8 +117,8 @@ class ProvisioningResult(BaseModel):
|
|||||||
"""Result of provisioning attempt."""
|
"""Result of provisioning attempt."""
|
||||||
|
|
||||||
success: bool = Field(description="Whether provisioning was initiated")
|
success: bool = Field(description="Whether provisioning was initiated")
|
||||||
authorization_url: Optional[str] = Field(
|
provisioning_url: Optional[str] = Field(
|
||||||
None, description="URL for user to complete OAuth authorization"
|
None, description="URL to Astrolabe settings for provisioning background sync"
|
||||||
)
|
)
|
||||||
message: str = Field(description="Status message for the user")
|
message: str = Field(description="Status message for the user")
|
||||||
already_provisioned: bool = Field(
|
already_provisioned: bool = Field(
|
||||||
@@ -142,8 +146,9 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
"""
|
"""
|
||||||
Check the provisioning status for Nextcloud access.
|
Check the provisioning status for Nextcloud access.
|
||||||
|
|
||||||
This checks whether the user has completed Flow 2 to provision
|
Checks for both credential types:
|
||||||
offline access to Nextcloud resources.
|
1. App password from Astrolabe (works today)
|
||||||
|
2. OAuth refresh token from storage (for future)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mcp: MCP context
|
mcp: MCP context
|
||||||
@@ -152,6 +157,37 @@ 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()
|
||||||
|
|
||||||
|
# Check for app password first (interim solution)
|
||||||
|
if settings.oidc_client_id and settings.oidc_client_secret:
|
||||||
|
try:
|
||||||
|
astrolabe = AstrolabeClient(
|
||||||
|
nextcloud_host=settings.nextcloud_host or "",
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
)
|
||||||
|
status = await astrolabe.get_background_sync_status(user_id)
|
||||||
|
|
||||||
|
if status.get("has_access"):
|
||||||
|
logger.info(
|
||||||
|
f" get_provisioning_status: ✓ App password FOUND for user_id={user_id}"
|
||||||
|
)
|
||||||
|
provisioned_at_str = status.get("provisioned_at")
|
||||||
|
return ProvisioningStatus(
|
||||||
|
is_provisioned=True,
|
||||||
|
provisioned_at=provisioned_at_str,
|
||||||
|
credential_type="app_password",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" App password check failed for {user_id}: {e}")
|
||||||
|
|
||||||
|
# Check for OAuth refresh token (fallback)
|
||||||
logger.info(
|
logger.info(
|
||||||
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
|
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
|
||||||
)
|
)
|
||||||
@@ -162,7 +198,7 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
logger.info(
|
logger.info(
|
||||||
f" get_provisioning_status: ✗ No refresh token found for user_id={user_id}"
|
f" get_provisioning_status: ✗ No credentials found for user_id={user_id}"
|
||||||
)
|
)
|
||||||
return ProvisioningStatus(is_provisioned=False)
|
return ProvisioningStatus(is_provisioned=False)
|
||||||
|
|
||||||
@@ -177,14 +213,13 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
# Convert timestamp to ISO format if present
|
# Convert timestamp to ISO format if present
|
||||||
provisioned_at_str = None
|
provisioned_at_str = None
|
||||||
if token_data.get("provisioned_at"):
|
if token_data.get("provisioned_at"):
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
||||||
provisioned_at_str = dt.isoformat()
|
provisioned_at_str = dt.isoformat()
|
||||||
|
|
||||||
return ProvisioningStatus(
|
return ProvisioningStatus(
|
||||||
is_provisioned=True,
|
is_provisioned=True,
|
||||||
provisioned_at=provisioned_at_str,
|
provisioned_at=provisioned_at_str,
|
||||||
|
credential_type="refresh_token",
|
||||||
client_id=token_data.get("provisioning_client_id"),
|
client_id=token_data.get("provisioning_client_id"),
|
||||||
scopes=token_data.get("scopes"),
|
scopes=token_data.get("scopes"),
|
||||||
flow_type=token_data.get("flow_type", "hybrid"),
|
flow_type=token_data.get("flow_type", "hybrid"),
|
||||||
@@ -238,36 +273,22 @@ async def provision_nextcloud_access(
|
|||||||
"""
|
"""
|
||||||
MCP Tool: Provision offline access to Nextcloud resources.
|
MCP Tool: Provision offline access to Nextcloud resources.
|
||||||
|
|
||||||
This tool initiates Flow 2 of the Progressive Consent architecture,
|
Returns URL to Astrolabe settings page where users can provision background
|
||||||
allowing the MCP server to obtain delegated access to Nextcloud APIs.
|
sync access using either:
|
||||||
|
- App password (works today, interim solution)
|
||||||
The user must complete the OAuth flow in their browser to grant access.
|
- OAuth refresh token (future, when Nextcloud supports OAuth for app APIs)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP context with user's Flow 1 token
|
ctx: MCP context with user's Flow 1 token
|
||||||
user_id: Optional user identifier (extracted from token if not provided)
|
user_id: Optional user identifier (extracted from token if not provided)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProvisioningResult with authorization URL or status
|
ProvisioningResult with Astrolabe settings URL or status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Extract user ID from the MCP access token (Flow 1 token)
|
# Extract user ID from the MCP access token (Flow 1 token)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
# Get the authorization token from context
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
|
||||||
token = ctx.authorization.token # type: ignore
|
|
||||||
# Decode token to get user info
|
|
||||||
try:
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
|
||||||
user_id = payload.get("sub", "unknown")
|
|
||||||
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to decode token: {e}")
|
|
||||||
user_id = "default_user"
|
|
||||||
else:
|
|
||||||
user_id = "default_user"
|
|
||||||
|
|
||||||
# Check if already provisioned
|
# Check if already provisioned
|
||||||
status = await get_provisioning_status(ctx, user_id)
|
status = await get_provisioning_status(ctx, user_id)
|
||||||
@@ -276,7 +297,8 @@ async def provision_nextcloud_access(
|
|||||||
success=True,
|
success=True,
|
||||||
already_provisioned=True,
|
already_provisioned=True,
|
||||||
message=(
|
message=(
|
||||||
f"Nextcloud access is already provisioned (since {status.provisioned_at}). "
|
f"Nextcloud access is already provisioned (credential_type={status.credential_type}, "
|
||||||
|
f"since {status.provisioned_at}). "
|
||||||
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -294,83 +316,20 @@ async def provision_nextcloud_access(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get MCP server's OAuth client credentials
|
# Return Astrolabe settings URL for background sync provisioning
|
||||||
# Try environment variable first, then fall back to DCR client_id
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
astrolabe_url = f"{nextcloud_host}/settings/user/astrolabe#background-sync"
|
||||||
if not server_client_id:
|
|
||||||
# Try to get from lifespan context (DCR)
|
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
|
||||||
if hasattr(lifespan_ctx, "server_client_id"):
|
|
||||||
server_client_id = lifespan_ctx.server_client_id
|
|
||||||
|
|
||||||
if not server_client_id:
|
|
||||||
return ProvisioningResult(
|
|
||||||
success=False,
|
|
||||||
message=(
|
|
||||||
"MCP server OAuth client not configured. "
|
|
||||||
"Set MCP_SERVER_CLIENT_ID environment variable or use Dynamic Client Registration."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate OAuth URL for Flow 2
|
|
||||||
oidc_discovery_url = os.getenv(
|
|
||||||
"OIDC_DISCOVERY_URL",
|
|
||||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate secure state for CSRF protection
|
|
||||||
state = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
# Store state in session for validation on callback
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
|
||||||
await storage.initialize()
|
|
||||||
|
|
||||||
# Create OAuth session for Flow 2
|
|
||||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
|
||||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
|
|
||||||
|
|
||||||
await storage.store_oauth_session(
|
|
||||||
session_id=session_id,
|
|
||||||
client_redirect_uri="", # No client redirect for Flow 2
|
|
||||||
state=state,
|
|
||||||
flow_type="flow2",
|
|
||||||
is_provisioning=True,
|
|
||||||
ttl_seconds=600, # 10 minute TTL
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define scopes for Nextcloud access
|
|
||||||
scopes = [
|
|
||||||
"openid",
|
|
||||||
"profile",
|
|
||||||
"email",
|
|
||||||
"offline_access", # Critical for background operations
|
|
||||||
"notes:read",
|
|
||||||
"notes:write",
|
|
||||||
"calendar:read",
|
|
||||||
"calendar:write",
|
|
||||||
"contacts:read",
|
|
||||||
"contacts:write",
|
|
||||||
"files:read",
|
|
||||||
"files:write",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Generate authorization URL
|
|
||||||
auth_url = generate_oauth_url_for_flow2(
|
|
||||||
oidc_discovery_url=oidc_discovery_url,
|
|
||||||
server_client_id=server_client_id,
|
|
||||||
redirect_uri=redirect_uri,
|
|
||||||
state=state,
|
|
||||||
scopes=scopes,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
success=True,
|
success=True,
|
||||||
authorization_url=auth_url,
|
provisioning_url=astrolabe_url,
|
||||||
message=(
|
message=(
|
||||||
"Please visit the authorization URL to grant the MCP server "
|
"Visit Astrolabe settings to provision background sync access.\n\n"
|
||||||
"offline access to your Nextcloud resources. This is a one-time "
|
"You can choose either:\n"
|
||||||
"setup that allows the server to access Nextcloud on your behalf "
|
"- App password (works today, recommended for now)\n"
|
||||||
"even when you're not actively connected."
|
"- OAuth refresh token (future, when Nextcloud fully supports OAuth)\n\n"
|
||||||
|
"After provisioning, background sync will enable the MCP server to "
|
||||||
|
"access Nextcloud resources even when you're not actively connected."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -417,11 +376,12 @@ async def revoke_nextcloud_access(
|
|||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
# Get OAuth client credentials from storage
|
||||||
if not encryption_key:
|
client_creds = await storage.get_oauth_client()
|
||||||
|
if not client_creds:
|
||||||
return RevocationResult(
|
return RevocationResult(
|
||||||
success=False,
|
success=False,
|
||||||
message="Token encryption key not configured.",
|
message="OAuth client credentials not found in storage.",
|
||||||
)
|
)
|
||||||
|
|
||||||
broker = TokenBrokerService(
|
broker = TokenBrokerService(
|
||||||
@@ -431,7 +391,8 @@ async def revoke_nextcloud_access(
|
|||||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
||||||
),
|
),
|
||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), # type: ignore
|
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), # type: ignore
|
||||||
encryption_key=encryption_key,
|
client_id=client_creds["client_id"],
|
||||||
|
client_secret=client_creds["client_secret"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Revoke access
|
# Revoke access
|
||||||
@@ -684,11 +645,16 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="provision_nextcloud_access",
|
name="provision_nextcloud_access",
|
||||||
|
title="Grant Server Access to Nextcloud",
|
||||||
description=(
|
description=(
|
||||||
"Provision offline access to Nextcloud resources. "
|
"Provision offline access to Nextcloud resources. "
|
||||||
"This is required before using Nextcloud tools. "
|
"This is required before using Nextcloud tools. "
|
||||||
"You'll need to complete an OAuth authorization in your browser."
|
"You'll need to complete an OAuth authorization in your browser."
|
||||||
),
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Creates new OAuth session each time
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("openid")
|
@require_scopes("openid")
|
||||||
async def tool_provision_access(
|
async def tool_provision_access(
|
||||||
@@ -699,7 +665,13 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="revoke_nextcloud_access",
|
name="revoke_nextcloud_access",
|
||||||
|
title="Revoke Server Access to Nextcloud",
|
||||||
description="Revoke offline access to Nextcloud resources.",
|
description="Revoke offline access to Nextcloud resources.",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Removes stored access tokens
|
||||||
|
idempotentHint=True, # Revoking revoked access = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("openid")
|
@require_scopes("openid")
|
||||||
async def tool_revoke_access(
|
async def tool_revoke_access(
|
||||||
@@ -709,7 +681,12 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="check_provisioning_status",
|
name="check_provisioning_status",
|
||||||
|
title="Check Provisioning Status",
|
||||||
description="Check whether Nextcloud access is provisioned.",
|
description="Check whether Nextcloud access is provisioned.",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Only checks status, doesn't modify
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("openid")
|
@require_scopes("openid")
|
||||||
async def tool_check_status(
|
async def tool_check_status(
|
||||||
@@ -719,10 +696,15 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="check_logged_in",
|
name="check_logged_in",
|
||||||
|
title="Check Server Login Status",
|
||||||
description=(
|
description=(
|
||||||
"Check if you are logged in to Nextcloud. "
|
"Check if you are logged in to Nextcloud. "
|
||||||
"If not logged in, this tool will prompt you to complete the login flow."
|
"If not logged in, this tool will prompt you to complete the login flow."
|
||||||
),
|
),
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Checking status doesn't modify state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("openid")
|
@require_scopes("openid")
|
||||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from mcp.types import (
|
|||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
SamplingMessage,
|
SamplingMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
|
ToolAnnotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
@@ -34,7 +35,13 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_semantic_tools(mcp: FastMCP):
|
def configure_semantic_tools(mcp: FastMCP):
|
||||||
"""Configure semantic search tools for MCP server."""
|
"""Configure semantic search tools for MCP server."""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Semantic Search",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=True, # Queries external Nextcloud service
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search(
|
async def nc_semantic_search(
|
||||||
@@ -58,13 +65,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
database for optimal relevance. This provides the best of both semantic
|
database for optimal relevance. This provides the best of both semantic
|
||||||
understanding and keyword precision.
|
understanding and keyword precision.
|
||||||
|
|
||||||
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
|
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
|
||||||
fully supported for indexing.
|
news items, and deck cards.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Natural language or keyword search query
|
query: Natural language or keyword search query
|
||||||
limit: Maximum number of results to return (default: 10)
|
limit: Maximum number of results to return (default: 10)
|
||||||
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
|
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
|
||||||
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
||||||
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
||||||
RRF: Good general-purpose fusion using reciprocal ranks
|
RRF: Good general-purpose fusion using reciprocal ranks
|
||||||
@@ -285,7 +292,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
logger.error(f"Search error: {e}", exc_info=True)
|
logger.error(f"Search error: {e}", exc_info=True)
|
||||||
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search with AI-Generated Answer",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=False, # Searches only indexed Nextcloud data
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search_answer(
|
async def nc_semantic_search_answer(
|
||||||
@@ -499,9 +512,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. Request LLM completion via MCP sampling with timeout
|
# 6. Request LLM completion via MCP sampling with timeout
|
||||||
|
# Note: 5 minute timeout to accommodate slower local LLMs (e.g., Ollama)
|
||||||
|
sampling_timeout_seconds = 300
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with anyio.fail_after(30):
|
with anyio.fail_after(sampling_timeout_seconds):
|
||||||
sampling_result = await ctx.session.create_message(
|
sampling_result = await ctx.session.create_message(
|
||||||
messages=[
|
messages=[
|
||||||
SamplingMessage(
|
SamplingMessage(
|
||||||
@@ -548,14 +563,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Sampling request timed out after 30 seconds for query: '{query}', "
|
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
|
||||||
f"returning search results only"
|
f"returning search results only"
|
||||||
)
|
)
|
||||||
return SamplingSearchResponse(
|
return SamplingSearchResponse(
|
||||||
query=query,
|
query=query,
|
||||||
generated_answer=(
|
generated_answer=(
|
||||||
f"[Sampling request timed out]\n\n"
|
f"[Sampling request timed out]\n\n"
|
||||||
f"The answer generation took too long (>30s). "
|
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
|
||||||
f"Found {len(accessible_results)} relevant documents. "
|
f"Found {len(accessible_results)} relevant documents. "
|
||||||
f"Please review the sources below or try a simpler query."
|
f"Please review the sources below or try a simpler query."
|
||||||
),
|
),
|
||||||
@@ -621,7 +636,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Check Indexing Status",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Only checks status
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||||
@@ -675,15 +696,22 @@ 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.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
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection
|
# Count documents in collection, excluding placeholders
|
||||||
|
# Placeholders are zero-vector points used to track processing state
|
||||||
count_result = await qdrant_client.count(
|
count_result = await qdrant_client.count(
|
||||||
collection_name=settings.get_collection_name()
|
collection_name=settings.get_collection_name(),
|
||||||
|
count_filter=Filter(must=[get_placeholder_filter()]),
|
||||||
)
|
)
|
||||||
indexed_count = count_result.count
|
indexed_count = count_result.count
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -16,7 +17,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
mcp: FastMCP server instance
|
mcp: FastMCP server instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Share",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_create(
|
async def nc_share_create(
|
||||||
@@ -56,7 +60,12 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Share",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||||
@@ -76,7 +85,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Share Details",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||||
@@ -95,7 +107,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
share_data = await client.sharing.get_share(share_id)
|
share_data = await client.sharing.get_share(share_id)
|
||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Shares",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_list(
|
async def nc_share_list(
|
||||||
@@ -117,7 +132,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
return json.dumps(shares, indent=2)
|
return json.dumps(shares, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Share",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_tables_tools(mcp: FastMCP):
|
def configure_tables_tools(mcp: FastMCP):
|
||||||
# Tables tools
|
# Tables tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Tables",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@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):
|
||||||
@@ -19,7 +23,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
return await client.tables.list_tables()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Table Schema",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
@@ -27,7 +34,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.get_table_schema(table_id)
|
return await client.tables.get_table_schema(table_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Read Table Rows",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_read_table(
|
async def nc_tables_read_table(
|
||||||
@@ -40,7 +50,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Insert Table Row",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||||
@@ -51,7 +64,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.create_row(table_id, data)
|
return await client.tables.create_row(table_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Table Row",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||||
@@ -62,7 +78,12 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.update_row(row_id, data)
|
return await client.tables.update_row(row_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Table Row",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
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
|
||||||
@@ -16,7 +17,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_webdav_tools(mcp: FastMCP):
|
def configure_webdav_tools(mcp: FastMCP):
|
||||||
# WebDAV file system tools
|
# WebDAV file system tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Files and Directories",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_directory(
|
async def nc_webdav_list_directory(
|
||||||
@@ -50,7 +57,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
total_size=total_size,
|
total_size=total_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Read File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
@@ -117,7 +130,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
"encoding": "base64",
|
"encoding": "base64",
|
||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Write File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=True, # HTTP PUT without version control is idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_write_file(
|
async def nc_webdav_write_file(
|
||||||
@@ -146,7 +165,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=True, # Creating existing dir returns 405 = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
@@ -161,7 +186,14 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.webdav.create_directory(path)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete File or Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Permanently deletes data
|
||||||
|
idempotentHint=True, # Deleting deleted resource = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
@@ -176,7 +208,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Move or Rename File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Moving changes source and dest
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_move_resource(
|
async def nc_webdav_move_resource(
|
||||||
@@ -197,7 +235,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Copy File or Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Creates new resource each time
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_copy_resource(
|
async def nc_webdav_copy_resource(
|
||||||
@@ -218,7 +262,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Files",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_search_files(
|
async def nc_webdav_search_files(
|
||||||
@@ -335,7 +385,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied=filters if filters else None,
|
filters_applied=filters if filters else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Files by Name",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_name(
|
async def nc_webdav_find_by_name(
|
||||||
@@ -363,7 +419,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied={"name_pattern": pattern},
|
filters_applied={"name_pattern": pattern},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Files by Type",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_type(
|
async def nc_webdav_find_by_type(
|
||||||
@@ -391,7 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied={"mime_type": mime_type},
|
filters_applied={"mime_type": mime_type},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Favorite Files",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_favorites(
|
async def nc_webdav_list_favorites(
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""HTML to Markdown conversion utilities for vector sync."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from markdownify import markdownify as md
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def html_to_markdown(html_content: str | None) -> str:
|
||||||
|
"""Convert HTML content to Markdown, preserving semantic structure.
|
||||||
|
|
||||||
|
This function converts HTML (typically from RSS/Atom feed items) to Markdown
|
||||||
|
for better text embedding. Markdown preserves:
|
||||||
|
- Heading hierarchy (important for document structure)
|
||||||
|
- Lists (bullet and numbered)
|
||||||
|
- Links (as [text](url))
|
||||||
|
- Bold/italic emphasis
|
||||||
|
- Paragraphs and line breaks
|
||||||
|
|
||||||
|
Args:
|
||||||
|
html_content: HTML string to convert (may be None or empty)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Markdown string, or empty string if input is None/empty
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> html_to_markdown("<h1>Title</h1><p>Content with <b>bold</b>.</p>")
|
||||||
|
'# Title\\n\\nContent with **bold**.\\n\\n'
|
||||||
|
"""
|
||||||
|
if not html_content:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
markdown = md(
|
||||||
|
html_content,
|
||||||
|
heading_style="ATX", # Use # style headings
|
||||||
|
strip=["script", "style", "iframe", "noscript"], # Remove unsafe elements
|
||||||
|
bullets="-", # Use - for unordered lists
|
||||||
|
code_language="", # Don't add language hints to code blocks
|
||||||
|
)
|
||||||
|
return markdown.strip()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to convert HTML to Markdown: {e}")
|
||||||
|
# Fallback: strip all HTML tags as a last resort
|
||||||
|
import re
|
||||||
|
|
||||||
|
text = re.sub(r"<[^>]+>", " ", html_content)
|
||||||
|
return " ".join(text.split()) # Normalize whitespace
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
"""OAuth mode vector sync orchestration.
|
||||||
|
|
||||||
|
Manages multi-user background vector sync when running in OAuth mode
|
||||||
|
with ENABLE_OFFLINE_ACCESS=true:
|
||||||
|
- User Manager: Monitors RefreshTokenStorage for user changes
|
||||||
|
- Per-User Scanners: One scanner task per provisioned user
|
||||||
|
- Shared Processor Pool: Processes documents from all users
|
||||||
|
|
||||||
|
Supports dual credential types for background sync:
|
||||||
|
- App passwords (interim solution, works today)
|
||||||
|
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
from anyio.abc import TaskGroup, TaskStatus
|
||||||
|
from anyio.streams.memory import (
|
||||||
|
MemoryObjectReceiveStream,
|
||||||
|
MemoryObjectSendStream,
|
||||||
|
)
|
||||||
|
from httpx import BasicAuth
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Scopes required for vector sync operations
|
||||||
|
VECTOR_SYNC_SCOPES = [
|
||||||
|
"notes:read",
|
||||||
|
"files:read",
|
||||||
|
"deck:read",
|
||||||
|
# "news:read", # News app may not be installed
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NotProvisionedError(Exception):
|
||||||
|
"""User has not provisioned offline access or has revoked it."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UserSyncState:
|
||||||
|
"""State for a single user's scanner task."""
|
||||||
|
|
||||||
|
user_id: str
|
||||||
|
cancel_scope: anyio.CancelScope
|
||||||
|
started_at: float = field(default_factory=time.time)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_client(
|
||||||
|
user_id: str,
|
||||||
|
token_broker: "TokenBrokerService",
|
||||||
|
nextcloud_host: str,
|
||||||
|
) -> NextcloudClient:
|
||||||
|
"""Get an authenticated NextcloudClient for a user.
|
||||||
|
|
||||||
|
Supports dual credential types with priority:
|
||||||
|
1. App password from Astrolabe (works today with BasicAuth)
|
||||||
|
2. OAuth refresh token from storage (for future when OAuth fully supported)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User identifier
|
||||||
|
token_broker: Token broker for obtaining access tokens
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Authenticated NextcloudClient
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
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)
|
||||||
|
if not token:
|
||||||
|
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
|
||||||
|
|
||||||
|
return NextcloudClient.from_token(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
token=token,
|
||||||
|
username=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_scanner_task(
|
||||||
|
user_id: str,
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
shutdown_event: anyio.Event,
|
||||||
|
wake_event: anyio.Event,
|
||||||
|
token_broker: "TokenBrokerService",
|
||||||
|
nextcloud_host: str,
|
||||||
|
*,
|
||||||
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
|
) -> None:
|
||||||
|
"""Scanner task for a single user in OAuth mode.
|
||||||
|
|
||||||
|
Gets a fresh token at the start of each scan cycle.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User to scan
|
||||||
|
send_stream: Stream to send changed documents to processors
|
||||||
|
shutdown_event: Event signaling shutdown
|
||||||
|
wake_event: Event to trigger immediate scan
|
||||||
|
token_broker: Token broker for obtaining access tokens
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
task_status: Status object for signaling task readiness
|
||||||
|
"""
|
||||||
|
logger.info(f"[OAuth] Scanner started for user: {user_id}")
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
nc_client = None
|
||||||
|
try:
|
||||||
|
# Get fresh token for this scan cycle
|
||||||
|
nc_client = await get_user_client(user_id, token_broker, nextcloud_host)
|
||||||
|
|
||||||
|
# Scan user's documents
|
||||||
|
await scan_user_documents(
|
||||||
|
user_id=user_id,
|
||||||
|
send_stream=send_stream,
|
||||||
|
nc_client=nc_client,
|
||||||
|
)
|
||||||
|
|
||||||
|
except NotProvisionedError:
|
||||||
|
logger.warning(
|
||||||
|
f"[OAuth] User {user_id} no longer provisioned, stopping scanner"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[OAuth] Scanner error for {user_id}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if nc_client:
|
||||||
|
await nc_client.close()
|
||||||
|
|
||||||
|
# Sleep until next interval or wake event
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||||
|
await wake_event.wait()
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"[OAuth] Scanner stopped for user: {user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def oauth_processor_task(
|
||||||
|
worker_id: int,
|
||||||
|
receive_stream: MemoryObjectReceiveStream[DocumentTask],
|
||||||
|
shutdown_event: anyio.Event,
|
||||||
|
token_broker: "TokenBrokerService",
|
||||||
|
nextcloud_host: str,
|
||||||
|
*,
|
||||||
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
|
) -> None:
|
||||||
|
"""Processor task for OAuth mode.
|
||||||
|
|
||||||
|
Handles documents from any user by fetching tokens on-demand.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
worker_id: Worker identifier for logging
|
||||||
|
receive_stream: Stream to receive documents from
|
||||||
|
shutdown_event: Event signaling shutdown
|
||||||
|
token_broker: Token broker for obtaining access tokens
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
task_status: Status object for signaling task readiness
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.vector.processor import process_document
|
||||||
|
|
||||||
|
logger.info(f"[OAuth] Processor {worker_id} started")
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
doc_task = None
|
||||||
|
nc_client = None
|
||||||
|
try:
|
||||||
|
# Get document with timeout
|
||||||
|
with anyio.fail_after(1.0):
|
||||||
|
doc_task = await receive_stream.receive()
|
||||||
|
|
||||||
|
# Get token for THIS document's user
|
||||||
|
nc_client = await get_user_client(
|
||||||
|
doc_task.user_id, token_broker, nextcloud_host
|
||||||
|
)
|
||||||
|
|
||||||
|
# Process the document
|
||||||
|
await process_document(doc_task, nc_client)
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
except anyio.EndOfStream:
|
||||||
|
logger.info(f"[OAuth] Processor {worker_id}: Stream closed, exiting")
|
||||||
|
break
|
||||||
|
|
||||||
|
except NotProvisionedError:
|
||||||
|
if doc_task:
|
||||||
|
logger.warning(
|
||||||
|
f"[OAuth] User {doc_task.user_id} not provisioned, "
|
||||||
|
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if doc_task:
|
||||||
|
logger.error(
|
||||||
|
f"[OAuth] Processor {worker_id} error processing "
|
||||||
|
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error(f"[OAuth] Processor {worker_id} error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if nc_client:
|
||||||
|
await nc_client.close()
|
||||||
|
|
||||||
|
logger.info(f"[OAuth] Processor {worker_id} stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_user_scanner_with_scope(
|
||||||
|
user_id: str,
|
||||||
|
cancel_scope: anyio.CancelScope,
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
shutdown_event: anyio.Event,
|
||||||
|
wake_event: anyio.Event,
|
||||||
|
token_broker: "TokenBrokerService",
|
||||||
|
nextcloud_host: str,
|
||||||
|
user_states: dict[str, UserSyncState],
|
||||||
|
) -> None:
|
||||||
|
"""Wrapper to run scanner with cancellation scope.
|
||||||
|
|
||||||
|
Cleans up user state on exit.
|
||||||
|
"""
|
||||||
|
cloned_stream = send_stream.clone()
|
||||||
|
try:
|
||||||
|
with cancel_scope:
|
||||||
|
await user_scanner_task(
|
||||||
|
user_id=user_id,
|
||||||
|
send_stream=cloned_stream,
|
||||||
|
shutdown_event=shutdown_event,
|
||||||
|
wake_event=wake_event,
|
||||||
|
token_broker=token_broker,
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Clean up on exit
|
||||||
|
if user_id in user_states:
|
||||||
|
del user_states[user_id]
|
||||||
|
await cloned_stream.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def user_manager_task(
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
shutdown_event: anyio.Event,
|
||||||
|
wake_event: anyio.Event,
|
||||||
|
token_broker: "TokenBrokerService",
|
||||||
|
refresh_token_storage: "RefreshTokenStorage",
|
||||||
|
nextcloud_host: str,
|
||||||
|
user_states: dict[str, UserSyncState],
|
||||||
|
tg: TaskGroup,
|
||||||
|
*,
|
||||||
|
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
|
||||||
|
) -> None:
|
||||||
|
"""Supervisor task that manages per-user scanners.
|
||||||
|
|
||||||
|
Periodically polls RefreshTokenStorage to detect:
|
||||||
|
- New users who have provisioned offline access -> start scanner
|
||||||
|
- Users who have revoked access -> cancel their scanner
|
||||||
|
|
||||||
|
Args:
|
||||||
|
send_stream: Stream to send documents to processors
|
||||||
|
shutdown_event: Event signaling shutdown
|
||||||
|
wake_event: Event to wake scanners for immediate scan
|
||||||
|
token_broker: Token broker for obtaining access tokens
|
||||||
|
refresh_token_storage: Storage for refresh tokens
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
user_states: Shared dict tracking active user scanners
|
||||||
|
tg: Task group for spawning scanner tasks
|
||||||
|
task_status: Status object for signaling task readiness
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
poll_interval = settings.vector_sync_user_poll_interval
|
||||||
|
|
||||||
|
logger.info(f"[OAuth] User manager started (poll interval: {poll_interval}s)")
|
||||||
|
task_status.started()
|
||||||
|
|
||||||
|
while not shutdown_event.is_set():
|
||||||
|
try:
|
||||||
|
# Get current provisioned users
|
||||||
|
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
|
||||||
|
active_users = set(user_states.keys())
|
||||||
|
|
||||||
|
# Start scanners for new users
|
||||||
|
new_users = provisioned_users - active_users
|
||||||
|
for user_id in new_users:
|
||||||
|
logger.info(
|
||||||
|
f"[OAuth] Starting scanner for newly provisioned user: {user_id}"
|
||||||
|
)
|
||||||
|
cancel_scope = anyio.CancelScope()
|
||||||
|
user_states[user_id] = UserSyncState(
|
||||||
|
user_id=user_id,
|
||||||
|
cancel_scope=cancel_scope,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start scanner in task group
|
||||||
|
tg.start_soon(
|
||||||
|
_run_user_scanner_with_scope,
|
||||||
|
user_id,
|
||||||
|
cancel_scope,
|
||||||
|
send_stream,
|
||||||
|
shutdown_event,
|
||||||
|
wake_event,
|
||||||
|
token_broker,
|
||||||
|
nextcloud_host,
|
||||||
|
user_states,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cancel scanners for revoked users
|
||||||
|
revoked_users = active_users - provisioned_users
|
||||||
|
for user_id in revoked_users:
|
||||||
|
logger.info(f"[OAuth] Stopping scanner for revoked user: {user_id}")
|
||||||
|
state = user_states.get(user_id)
|
||||||
|
if state:
|
||||||
|
state.cancel_scope.cancel()
|
||||||
|
# Note: state will be removed by _run_user_scanner_with_scope on exit
|
||||||
|
|
||||||
|
if new_users:
|
||||||
|
logger.info(f"[OAuth] Started {len(new_users)} new scanner(s)")
|
||||||
|
if revoked_users:
|
||||||
|
logger.info(f"[OAuth] Stopped {len(revoked_users)} scanner(s)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[OAuth] User manager error: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Sleep until next poll
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(poll_interval):
|
||||||
|
await shutdown_event.wait()
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
break
|
||||||
|
|
||||||
|
# Cancel all remaining scanners on shutdown
|
||||||
|
logger.info(
|
||||||
|
f"[OAuth] User manager shutting down, cancelling {len(user_states)} scanner(s)"
|
||||||
|
)
|
||||||
|
for state in list(user_states.values()):
|
||||||
|
state.cancel_scope.cancel()
|
||||||
|
|
||||||
|
logger.info("[OAuth] User manager stopped")
|
||||||
@@ -6,6 +6,7 @@ Processes documents from stream: fetches content, generates embeddings, stores i
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
@@ -272,6 +273,141 @@ async def _index_document(
|
|||||||
file_path = None # Notes don't have file paths
|
file_path = None # Notes don't have file paths
|
||||||
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":
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
|
|
||||||
|
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||||
|
# Convert HTML body to Markdown for better embedding
|
||||||
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
|
# Build content: title + URL + body
|
||||||
|
item_title = item.get("title", "")
|
||||||
|
item_url = item.get("url", "")
|
||||||
|
feed_title = item.get("feedTitle", "")
|
||||||
|
|
||||||
|
# Structure content for embedding
|
||||||
|
content_parts = [item_title]
|
||||||
|
if feed_title:
|
||||||
|
content_parts.append(f"Source: {feed_title}")
|
||||||
|
if item_url:
|
||||||
|
content_parts.append(f"URL: {item_url}")
|
||||||
|
content_parts.append("") # Blank line
|
||||||
|
content_parts.append(body_markdown)
|
||||||
|
content = "\n".join(content_parts)
|
||||||
|
|
||||||
|
title = item_title
|
||||||
|
etag = item.get("guidHash", "")
|
||||||
|
# Store news-specific metadata for later use in payload
|
||||||
|
file_metadata = {
|
||||||
|
"feed_id": item.get("feedId"),
|
||||||
|
"feed_title": feed_title,
|
||||||
|
"author": item.get("author"),
|
||||||
|
"pub_date": item.get("pubDate"),
|
||||||
|
"starred": item.get("starred", False),
|
||||||
|
"unread": item.get("unread", True),
|
||||||
|
"url": item_url,
|
||||||
|
"guid_hash": item.get("guidHash"),
|
||||||
|
"enclosure_link": item.get("enclosureLink"),
|
||||||
|
"enclosure_mime": item.get("enclosureMime"),
|
||||||
|
}
|
||||||
|
file_path = None
|
||||||
|
content_bytes = None
|
||||||
|
content_type = None
|
||||||
|
elif doc_task.doc_type == "deck_card":
|
||||||
|
# Fetch card from Deck API
|
||||||
|
# Use metadata from scanner if available (O(1) lookup)
|
||||||
|
# Otherwise fall back to iteration (legacy data)
|
||||||
|
card = None
|
||||||
|
board = None
|
||||||
|
stack = None
|
||||||
|
|
||||||
|
if (
|
||||||
|
doc_task.metadata
|
||||||
|
and "board_id" in doc_task.metadata
|
||||||
|
and "stack_id" in doc_task.metadata
|
||||||
|
):
|
||||||
|
# Fast path: Direct lookup with known board_id/stack_id
|
||||||
|
board_id = doc_task.metadata["board_id"]
|
||||||
|
stack_id = doc_task.metadata["stack_id"]
|
||||||
|
try:
|
||||||
|
card = await nc_client.deck.get_card(
|
||||||
|
board_id=int(board_id),
|
||||||
|
stack_id=int(stack_id),
|
||||||
|
card_id=int(doc_task.doc_id),
|
||||||
|
)
|
||||||
|
# Fetch board and stack info for metadata
|
||||||
|
boards = await nc_client.deck.get_boards()
|
||||||
|
for b in boards:
|
||||||
|
if b.id == int(board_id):
|
||||||
|
board = b
|
||||||
|
stacks = await nc_client.deck.get_stacks(b.id)
|
||||||
|
for s in stacks:
|
||||||
|
if s.id == int(stack_id):
|
||||||
|
stack = s
|
||||||
|
break
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to fetch card with metadata (board_id={board_id}, stack_id={stack_id}, card_id={doc_task.doc_id}): {e}, falling back to iteration"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
|
||||||
|
if card is None:
|
||||||
|
boards = await nc_client.deck.get_boards()
|
||||||
|
card_found = False
|
||||||
|
|
||||||
|
for b in boards:
|
||||||
|
if card_found:
|
||||||
|
break
|
||||||
|
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||||
|
if b.deletedAt > 0:
|
||||||
|
continue
|
||||||
|
stacks = await nc_client.deck.get_stacks(b.id)
|
||||||
|
for s in stacks:
|
||||||
|
if card_found:
|
||||||
|
break
|
||||||
|
if s.cards:
|
||||||
|
for c in s.cards:
|
||||||
|
if c.id == int(doc_task.doc_id):
|
||||||
|
card = c
|
||||||
|
board = b
|
||||||
|
stack = s
|
||||||
|
card_found = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not card_found:
|
||||||
|
raise ValueError(
|
||||||
|
f"Deck card {doc_task.doc_id} not found in any board/stack"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Type narrowing: card, board, stack are all set if we reach here
|
||||||
|
assert card is not None
|
||||||
|
assert board is not None
|
||||||
|
assert stack is not None
|
||||||
|
|
||||||
|
# Build content from card title and description
|
||||||
|
content_parts = [card.title]
|
||||||
|
if card.description:
|
||||||
|
content_parts.append(card.description)
|
||||||
|
content = "\n\n".join(content_parts)
|
||||||
|
title = card.title
|
||||||
|
|
||||||
|
# Store deck-specific metadata
|
||||||
|
file_metadata = {
|
||||||
|
"board_id": board.id,
|
||||||
|
"board_title": board.title,
|
||||||
|
"stack_id": stack.id,
|
||||||
|
"stack_title": stack.title,
|
||||||
|
"card_type": card.type,
|
||||||
|
"duedate": (card.duedate.isoformat() if card.duedate else None),
|
||||||
|
"archived": card.archived,
|
||||||
|
"owner": (
|
||||||
|
card.owner.uid if hasattr(card.owner, "uid") else str(card.owner)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
etag = card.etag or ""
|
||||||
|
file_path = None
|
||||||
|
content_bytes = None
|
||||||
|
content_type = None
|
||||||
elif doc_task.doc_type == "file":
|
elif doc_task.doc_type == "file":
|
||||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||||
if not doc_task.file_path:
|
if not doc_task.file_path:
|
||||||
@@ -358,15 +494,18 @@ async def _index_document(
|
|||||||
chunks = await chunker.chunk_text(content)
|
chunks = await chunker.chunk_text(content)
|
||||||
|
|
||||||
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
||||||
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
|
page_boundaries = file_metadata.get("page_boundaries")
|
||||||
|
if doc_task.doc_type == "file" and page_boundaries is not None:
|
||||||
|
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||||
|
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_sync.assign_page_numbers",
|
"vector_sync.assign_page_numbers",
|
||||||
attributes={
|
attributes={
|
||||||
"vector_sync.chunk_count": len(chunks),
|
"vector_sync.chunk_count": len(chunks),
|
||||||
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
|
"vector_sync.page_count": len(page_boundaries_list),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
assign_page_numbers(chunks, file_metadata["page_boundaries"])
|
assign_page_numbers(chunks, page_boundaries_list)
|
||||||
|
|
||||||
# Diagnostic: Verify page number assignment
|
# Diagnostic: Verify page number assignment
|
||||||
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
||||||
@@ -389,8 +528,8 @@ async def _index_document(
|
|||||||
f"Text length: {len(content)}, "
|
f"Text length: {len(content)}, "
|
||||||
f"Chunks: {len(chunks)}, "
|
f"Chunks: {len(chunks)}, "
|
||||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||||
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
|
f"Page boundaries: {len(page_boundaries_list)} pages, "
|
||||||
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
|
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract chunk texts for embedding
|
# Extract chunk texts for embedding
|
||||||
@@ -464,6 +603,9 @@ async def _index_document(
|
|||||||
logger.warning("No page boundaries available, skipping highlighting")
|
logger.warning("No page boundaries available, skipping highlighting")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||||
|
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
||||||
)
|
)
|
||||||
@@ -474,7 +616,7 @@ async def _index_document(
|
|||||||
lambda: PDFHighlighter.highlight_chunks_batch(
|
lambda: PDFHighlighter.highlight_chunks_batch(
|
||||||
pdf_bytes=content_bytes,
|
pdf_bytes=content_bytes,
|
||||||
chunks=chunk_data,
|
chunks=chunk_data,
|
||||||
page_boundaries=page_boundaries,
|
page_boundaries=page_boundaries_list,
|
||||||
full_text=content,
|
full_text=content,
|
||||||
color="yellow",
|
color="yellow",
|
||||||
zoom=2.0,
|
zoom=2.0,
|
||||||
@@ -566,6 +708,37 @@ async def _index_document(
|
|||||||
if doc_task.doc_type == "file"
|
if doc_task.doc_type == "file"
|
||||||
else {}
|
else {}
|
||||||
),
|
),
|
||||||
|
# News item-specific metadata
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"feed_id": file_metadata.get("feed_id"),
|
||||||
|
"feed_title": file_metadata.get("feed_title"),
|
||||||
|
"author": file_metadata.get("author"),
|
||||||
|
"pub_date": file_metadata.get("pub_date"),
|
||||||
|
"starred": file_metadata.get("starred"),
|
||||||
|
"unread": file_metadata.get("unread"),
|
||||||
|
"url": file_metadata.get("url"),
|
||||||
|
"guid_hash": file_metadata.get("guid_hash"),
|
||||||
|
"enclosure_link": file_metadata.get("enclosure_link"),
|
||||||
|
"enclosure_mime": file_metadata.get("enclosure_mime"),
|
||||||
|
}
|
||||||
|
if doc_task.doc_type == "news_item"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
# Deck card-specific metadata
|
||||||
|
**(
|
||||||
|
{
|
||||||
|
"board_id": file_metadata.get("board_id"),
|
||||||
|
"board_title": file_metadata.get("board_title"),
|
||||||
|
"stack_id": file_metadata.get("stack_id"),
|
||||||
|
"stack_title": file_metadata.get("stack_title"),
|
||||||
|
"card_type": file_metadata.get("card_type"),
|
||||||
|
"duedate": file_metadata.get("duedate"),
|
||||||
|
"owner": file_metadata.get("owner"),
|
||||||
|
}
|
||||||
|
if doc_task.doc_type == "deck_card"
|
||||||
|
else {}
|
||||||
|
),
|
||||||
# Highlighted page image (PDF only)
|
# Highlighted page image (PDF only)
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -89,6 +89,8 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
|||||||
if isinstance(vectors, dict):
|
if isinstance(vectors, dict):
|
||||||
actual_dimension = vectors["dense"].size
|
actual_dimension = vectors["dense"].size
|
||||||
else:
|
else:
|
||||||
|
# Type narrowing: vectors must be VectorParams if not dict
|
||||||
|
assert isinstance(vectors, VectorParams)
|
||||||
actual_dimension = vectors.size
|
actual_dimension = vectors.size
|
||||||
|
|
||||||
# Validate dimension matches
|
# Validate dimension matches
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class DocumentTask:
|
|||||||
operation: str # "index" or "delete"
|
operation: str # "index" or "delete"
|
||||||
modified_at: int
|
modified_at: int
|
||||||
file_path: str | None = None # File path for files (when doc_id is file_id)
|
file_path: str | None = None # File path for files (when doc_id is file_id)
|
||||||
|
metadata: dict[str, int | str] | None = (
|
||||||
|
None # Additional metadata (e.g., board_id/stack_id for deck_card)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Track documents potentially deleted (grace period before actual deletion)
|
# Track documents potentially deleted (grace period before actual deletion)
|
||||||
@@ -79,9 +82,11 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
|
|||||||
|
|
||||||
if scroll_result[0]:
|
if scroll_result[0]:
|
||||||
timestamps = [
|
timestamps = [
|
||||||
point.payload.get("indexed_at", 0) for point in scroll_result[0]
|
point.payload.get("indexed_at", 0)
|
||||||
|
for point in scroll_result[0]
|
||||||
|
if point.payload is not None
|
||||||
]
|
]
|
||||||
max_timestamp = max(timestamps)
|
max_timestamp = max(timestamps) if timestamps else 0
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
|
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
|
||||||
)
|
)
|
||||||
@@ -206,7 +211,11 @@ async def scan_user_documents(
|
|||||||
limit=10000,
|
limit=10000,
|
||||||
)
|
)
|
||||||
|
|
||||||
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
|
indexed_doc_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
||||||
|
|
||||||
@@ -376,7 +385,9 @@ async def scan_user_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
indexed_file_ids = {
|
indexed_file_ids = {
|
||||||
point.payload["doc_id"] for point in file_scroll_result[0]
|
point.payload["doc_id"]
|
||||||
|
for point in (file_scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
||||||
@@ -544,9 +555,419 @@ async def scan_user_documents(
|
|||||||
|
|
||||||
queued += file_queued
|
queued += file_queued
|
||||||
|
|
||||||
|
# Scan News items (starred + unread)
|
||||||
|
news_queued = 0
|
||||||
|
try:
|
||||||
|
news_queued = await scan_news_items(
|
||||||
|
user_id=user_id,
|
||||||
|
send_stream=send_stream,
|
||||||
|
nc_client=nc_client,
|
||||||
|
initial_sync=initial_sync,
|
||||||
|
scan_id=scan_id,
|
||||||
|
)
|
||||||
|
queued += news_queued
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to scan news items for {user_id}: {e}")
|
||||||
|
|
||||||
|
# Scan Deck cards
|
||||||
|
deck_queued = 0
|
||||||
|
try:
|
||||||
|
deck_queued = await scan_deck_cards(
|
||||||
|
user_id=user_id,
|
||||||
|
send_stream=send_stream,
|
||||||
|
nc_client=nc_client,
|
||||||
|
initial_sync=initial_sync,
|
||||||
|
scan_id=scan_id,
|
||||||
|
)
|
||||||
|
queued += deck_queued
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to scan deck cards for {user_id}: {e}")
|
||||||
|
|
||||||
if queued > 0:
|
if queued > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
|
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No changes detected for {user_id}")
|
logger.debug(f"No changes detected for {user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_news_items(
|
||||||
|
user_id: str,
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
nc_client: NextcloudClient,
|
||||||
|
initial_sync: bool,
|
||||||
|
scan_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Scan user's News items and queue changed items for indexing.
|
||||||
|
|
||||||
|
Indexes all items from the user's feeds. The News app's auto-purge
|
||||||
|
feature (default: 200 items per feed) naturally limits the total
|
||||||
|
number of items, making explicit filtering unnecessary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User to scan
|
||||||
|
send_stream: Stream to send changed documents to processors
|
||||||
|
nc_client: Authenticated Nextcloud client
|
||||||
|
initial_sync: If True, send all documents (first-time sync)
|
||||||
|
scan_id: Scan identifier for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of items queued for processing
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
queued = 0
|
||||||
|
|
||||||
|
# Get indexed news item IDs from Qdrant (for deletion tracking)
|
||||||
|
indexed_item_ids: set[str] = set()
|
||||||
|
if not initial_sync:
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
scroll_result = await qdrant_client.scroll(
|
||||||
|
collection_name=settings.get_collection_name(),
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||||
|
FieldCondition(key="doc_type", match=MatchValue(value="news_item")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
with_payload=["doc_id"],
|
||||||
|
with_vectors=False,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
indexed_item_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant")
|
||||||
|
|
||||||
|
# Fetch all items (News app caps at ~200 per feed via auto-purge)
|
||||||
|
all_items = await nc_client.news.get_items(
|
||||||
|
batch_size=-1,
|
||||||
|
type_=NewsItemType.ALL,
|
||||||
|
get_read=True,
|
||||||
|
)
|
||||||
|
logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items")
|
||||||
|
|
||||||
|
item_count = len(all_items)
|
||||||
|
nextcloud_item_ids: set[str] = set()
|
||||||
|
|
||||||
|
for item in all_items:
|
||||||
|
doc_id = str(item["id"])
|
||||||
|
nextcloud_item_ids.add(doc_id)
|
||||||
|
|
||||||
|
# Use lastModified timestamp (microseconds in News API)
|
||||||
|
modified_at = item.get("lastModified", 0)
|
||||||
|
# Convert to seconds if needed (News API uses microseconds)
|
||||||
|
if modified_at > 10000000000: # > year 2286 in seconds
|
||||||
|
modified_at = modified_at // 1000000
|
||||||
|
|
||||||
|
if initial_sync:
|
||||||
|
# Send everything on first sync - write placeholder first
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
else:
|
||||||
|
# Incremental sync: check if item exists and compare modified_at
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
logger.debug(
|
||||||
|
f"News item {doc_id} reappeared, removing from deletion grace period"
|
||||||
|
)
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
|
||||||
|
# Query Qdrant for existing entry
|
||||||
|
existing_metadata = await query_document_metadata(
|
||||||
|
doc_id=doc_id, doc_type="news_item", user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_indexing = False
|
||||||
|
if existing_metadata is None:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("is_placeholder", False):
|
||||||
|
queued_at = existing_metadata.get("queued_at", 0)
|
||||||
|
placeholder_age = time.time() - queued_at
|
||||||
|
stale_threshold = settings.vector_sync_scan_interval * 5
|
||||||
|
if placeholder_age > stale_threshold:
|
||||||
|
logger.debug(
|
||||||
|
f"Found stale placeholder for news item {doc_id} "
|
||||||
|
f"(age={placeholder_age:.1f}s), requeuing"
|
||||||
|
)
|
||||||
|
needs_indexing = True
|
||||||
|
|
||||||
|
if needs_indexing:
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}"
|
||||||
|
)
|
||||||
|
record_vector_sync_scan(item_count)
|
||||||
|
|
||||||
|
# Check for deleted items (not initial sync)
|
||||||
|
# Items become "deleted" when they are no longer starred AND become read
|
||||||
|
if not initial_sync:
|
||||||
|
grace_period = settings.vector_sync_scan_interval * 1.5
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for doc_id in indexed_item_ids:
|
||||||
|
if doc_id not in nextcloud_item_ids:
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
first_missing_time = _potentially_deleted[doc_key]
|
||||||
|
time_missing = current_time - first_missing_time
|
||||||
|
|
||||||
|
if time_missing >= grace_period:
|
||||||
|
logger.info(
|
||||||
|
f"News item {doc_id} missing for {time_missing:.1f}s "
|
||||||
|
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="news_item",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"News item {doc_id} missing for first time, starting grace period"
|
||||||
|
)
|
||||||
|
_potentially_deleted[doc_key] = current_time
|
||||||
|
|
||||||
|
return queued
|
||||||
|
|
||||||
|
|
||||||
|
async def scan_deck_cards(
|
||||||
|
user_id: str,
|
||||||
|
send_stream: MemoryObjectSendStream[DocumentTask],
|
||||||
|
nc_client: NextcloudClient,
|
||||||
|
initial_sync: bool,
|
||||||
|
scan_id: int,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Scan user's Deck cards and queue changed cards for indexing.
|
||||||
|
|
||||||
|
Indexes cards from all non-archived boards and stacks.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User to scan
|
||||||
|
send_stream: Stream to send changed documents to processors
|
||||||
|
nc_client: Authenticated Nextcloud client
|
||||||
|
initial_sync: If True, send all documents (first-time sync)
|
||||||
|
scan_id: Scan identifier for logging
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of cards queued for processing
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
queued = 0
|
||||||
|
|
||||||
|
# Get indexed deck card IDs from Qdrant (for deletion tracking)
|
||||||
|
indexed_card_ids: set[str] = set()
|
||||||
|
if not initial_sync:
|
||||||
|
qdrant_client = await get_qdrant_client()
|
||||||
|
scroll_result = await qdrant_client.scroll(
|
||||||
|
collection_name=settings.get_collection_name(),
|
||||||
|
scroll_filter=Filter(
|
||||||
|
must=[
|
||||||
|
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||||
|
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
with_payload=["doc_id"],
|
||||||
|
with_vectors=False,
|
||||||
|
limit=10000,
|
||||||
|
)
|
||||||
|
indexed_card_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant")
|
||||||
|
|
||||||
|
# Fetch all boards
|
||||||
|
boards = await nc_client.deck.get_boards()
|
||||||
|
logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards")
|
||||||
|
|
||||||
|
card_count = 0
|
||||||
|
nextcloud_card_ids: set[str] = set()
|
||||||
|
|
||||||
|
# Iterate through boards
|
||||||
|
for board in boards:
|
||||||
|
# Skip archived boards
|
||||||
|
if board.archived:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||||
|
if board.deletedAt > 0:
|
||||||
|
logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Get stacks for this board
|
||||||
|
stacks = await nc_client.deck.get_stacks(board.id)
|
||||||
|
|
||||||
|
# Iterate through stacks
|
||||||
|
for stack in stacks:
|
||||||
|
# Skip if stack has no cards
|
||||||
|
if not stack.cards:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Iterate through cards in stack
|
||||||
|
for card in stack.cards:
|
||||||
|
# Skip archived cards
|
||||||
|
if card.archived:
|
||||||
|
continue
|
||||||
|
|
||||||
|
card_count += 1
|
||||||
|
doc_id = str(card.id)
|
||||||
|
nextcloud_card_ids.add(doc_id)
|
||||||
|
|
||||||
|
# Use lastModified timestamp if available
|
||||||
|
modified_at = card.lastModified or 0
|
||||||
|
|
||||||
|
if initial_sync:
|
||||||
|
# Send everything on first sync - write placeholder first
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
metadata={"board_id": board.id, "stack_id": stack.id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
else:
|
||||||
|
# Incremental sync: check if card exists and compare modified_at
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
logger.debug(
|
||||||
|
f"Deck card {doc_id} reappeared, removing from deletion grace period"
|
||||||
|
)
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
|
||||||
|
# Query Qdrant for existing entry
|
||||||
|
existing_metadata = await query_document_metadata(
|
||||||
|
doc_id=doc_id, doc_type="deck_card", user_id=user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
needs_indexing = False
|
||||||
|
if existing_metadata is None:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("modified_at", 0) < modified_at:
|
||||||
|
needs_indexing = True
|
||||||
|
elif existing_metadata.get("is_placeholder", False):
|
||||||
|
queued_at = existing_metadata.get("queued_at", 0)
|
||||||
|
placeholder_age = time.time() - queued_at
|
||||||
|
stale_threshold = settings.vector_sync_scan_interval * 5
|
||||||
|
if placeholder_age > stale_threshold:
|
||||||
|
logger.debug(
|
||||||
|
f"Found stale placeholder for deck card {doc_id} "
|
||||||
|
f"(age={placeholder_age:.1f}s), requeuing"
|
||||||
|
)
|
||||||
|
needs_indexing = True
|
||||||
|
|
||||||
|
if needs_indexing:
|
||||||
|
await write_placeholder_point(
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
user_id=user_id,
|
||||||
|
modified_at=modified_at,
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
operation="index",
|
||||||
|
modified_at=modified_at,
|
||||||
|
metadata={"board_id": board.id, "stack_id": stack.id},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}"
|
||||||
|
)
|
||||||
|
record_vector_sync_scan(card_count)
|
||||||
|
|
||||||
|
# Check for deleted cards (not initial sync)
|
||||||
|
if not initial_sync:
|
||||||
|
grace_period = settings.vector_sync_scan_interval * 1.5
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for doc_id in indexed_card_ids:
|
||||||
|
if doc_id not in nextcloud_card_ids:
|
||||||
|
doc_key = (user_id, doc_id)
|
||||||
|
|
||||||
|
if doc_key in _potentially_deleted:
|
||||||
|
first_missing_time = _potentially_deleted[doc_key]
|
||||||
|
time_missing = current_time - first_missing_time
|
||||||
|
|
||||||
|
if time_missing >= grace_period:
|
||||||
|
logger.info(
|
||||||
|
f"Deck card {doc_id} missing for {time_missing:.1f}s "
|
||||||
|
f"(>{grace_period:.1f}s grace period), sending deletion"
|
||||||
|
)
|
||||||
|
await send_stream.send(
|
||||||
|
DocumentTask(
|
||||||
|
user_id=user_id,
|
||||||
|
doc_id=doc_id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
operation="delete",
|
||||||
|
modified_at=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
queued += 1
|
||||||
|
del _potentially_deleted[doc_key]
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"Deck card {doc_id} missing for first time, starting grace period"
|
||||||
|
)
|
||||||
|
_potentially_deleted[doc_key] = current_time
|
||||||
|
|
||||||
|
return queued
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user