Compare commits
165 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2773317ef | |||
| dce3ca9a70 | |||
| 18e5baf2a5 | |||
| 24bc29ea64 | |||
| 44e7e2e09b | |||
| bcc0bfee8d | |||
| 0f31d16158 | |||
| 7c0b84d398 | |||
| f51b27ba19 | |||
| 010eb40d5c | |||
| 76e6c12b56 | |||
| 76e305006c | |||
| 8887aa241a | |||
| 10d44edf4c | |||
| f5b4658d5a | |||
| 39d160ce48 | |||
| a11ae9c027 | |||
| 81efa6e263 | |||
| aaddd0d5a9 | |||
| a5eb16c1ac | |||
| 6f7a06e558 | |||
| 0e4c8453bf | |||
| 2dba3179bd | |||
| 5f0e208193 | |||
| 3779ec3e17 | |||
| f2df19c39b | |||
| 5562c943c0 | |||
| 12c02ffe00 | |||
| d2e1391f37 | |||
| ac91aacaf5 | |||
| ad9fcddca1 | |||
| 0e57cf6389 | |||
| b9a185ba1c | |||
| 9aa6b44397 | |||
| 1aa21663b7 | |||
| d145e4d5de | |||
| cf4ed4a641 | |||
| 8d84d95ada | |||
| 992d380585 | |||
| e51fc48206 | |||
| 2657071404 | |||
| 75325f16fc | |||
| 1d4ff3fbe0 | |||
| 778b08cc84 | |||
| 8cab588f21 | |||
| 8233cc9dcf | |||
| 0d259d2dfd | |||
| dfc676a847 | |||
| cf627a9c48 | |||
| 037e88e416 | |||
| dae2f276ae | |||
| d94610d0ec | |||
| af0b9c1f93 | |||
| 2d7360ebd7 | |||
| 56542802bc | |||
| c03dbd1b55 | |||
| 99925d9f22 | |||
| 0dfaf954d7 | |||
| b3fe7099cb | |||
| 7152537fd4 | |||
| 9d31925f27 | |||
| 3a322c34bc | |||
| b1bd025aac | |||
| 8a1c604d78 | |||
| 3616dee54c | |||
| dbb36a7b63 | |||
| f1797b2f8e | |||
| 1d5d4f86d7 | |||
| 44030805f1 | |||
| afd7e69f76 | |||
| 31be72ae24 | |||
| 6bd05a81bf | |||
| a4e3f0b354 | |||
| 0f23964752 | |||
| 66ccacdee1 | |||
| 1a4486a388 | |||
| 91d06acfb4 | |||
| 90874ca7cd | |||
| da8fed3382 | |||
| 8963e65f1b | |||
| 75c3868e74 | |||
| 1707b2e6e1 | |||
| df3cce4370 | |||
| 1c5e21843e | |||
| 520ef113ba | |||
| 3be229a487 | |||
| 6da69b0336 | |||
| 427e501691 | |||
| 9c275d1a3f | |||
| af43630ca7 | |||
| 49c5439686 | |||
| c5eec64716 | |||
| 3948f6a019 | |||
| 08d37a6597 | |||
| 4712235390 | |||
| d0f18b36e8 | |||
| aca0d236b4 | |||
| 7ab0dcd3d8 | |||
| eafef986f2 | |||
| 8126beb16e | |||
| bce6686494 | |||
| dfc75a8619 | |||
| 254cb6cf06 | |||
| 940e7d3e4e | |||
| ac985b265e | |||
| e7f452342e | |||
| e4b5617a55 | |||
| 291a13c064 | |||
| 0e9fee5616 | |||
| 093f1d7302 | |||
| 9da5f95bcb | |||
| 1d4aede0f9 | |||
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 119a422a35 | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c |
@@ -1,89 +0,0 @@
|
||||
name: Build and Publish Astrolabe App Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'astrolabe-v*'
|
||||
|
||||
env:
|
||||
APP_NAME: astrolabe
|
||||
APP_DIR: third_party/astrolabe
|
||||
|
||||
jobs:
|
||||
build-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
||||
- name: Get version from tag
|
||||
id: tag
|
||||
run: |
|
||||
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Validate version in info.xml matches tag
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
|
||||
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
|
||||
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version validated: $INFO_VERSION"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
coverage: none
|
||||
|
||||
- name: Checkout Nextcloud server (for signing)
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
repository: nextcloud/server
|
||||
ref: stable30
|
||||
path: server
|
||||
|
||||
- name: Install dependencies and build
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: |
|
||||
composer install --no-dev --optimize-autoloader
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup signing certificate
|
||||
run: |
|
||||
mkdir -p $HOME/.nextcloud/certificates
|
||||
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
|
||||
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
|
||||
|
||||
- name: Build app store package
|
||||
working-directory: ${{ env.APP_DIR }}
|
||||
run: make appstore server_dir=${{ github.workspace }}/server
|
||||
|
||||
- name: Create GitHub release and attach tarball
|
||||
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||
with:
|
||||
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
|
||||
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
tag: ${{ github.ref }}
|
||||
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
|
||||
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
|
||||
- name: Upload to Nextcloud App Store
|
||||
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
@@ -1,323 +0,0 @@
|
||||
# Consolidated CI workflow for Astrolabe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astrolabe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astrolabe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astrolabe/**'
|
||||
- '.github/workflows/astrolabe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astrolabe-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/astrolabe/src/**'
|
||||
- 'third_party/astrolabe/package.json'
|
||||
- 'third_party/astrolabe/package-lock.json'
|
||||
- 'third_party/astrolabe/vite.config.js'
|
||||
- 'third_party/astrolabe/**/*.js'
|
||||
- 'third_party/astrolabe/**/*.ts'
|
||||
- 'third_party/astrolabe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astrolabe/lib/**'
|
||||
- 'third_party/astrolabe/appinfo/**'
|
||||
- 'third_party/astrolabe/composer.json'
|
||||
- 'third_party/astrolabe/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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe
|
||||
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/astrolabe/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/astrolabe
|
||||
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/astrolabe/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/astrolabe/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
|
||||
|
||||
# PHPUnit Tests
|
||||
phpunit:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astrolabe
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
php-versions: ['8.1', '8.2', '8.3']
|
||||
|
||||
name: PHPUnit (PHP ${{ matrix.php-versions }})
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Set up PHP ${{ matrix.php-versions }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ matrix.php-versions }}
|
||||
extensions: ctype, curl, dom, gd, iconv, intl, json, mbstring, openssl, posix, sqlite, xml, zip
|
||||
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/astrolabe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for testing
|
||||
run: |
|
||||
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 PHPUnit
|
||||
run: composer run test:unit
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm, phpunit]
|
||||
if: always()
|
||||
name: astrolabe-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' || needs.phpunit.result != 'success') }}; then
|
||||
echo "PHP checks failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed"
|
||||
@@ -15,13 +15,13 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
fi
|
||||
}
|
||||
|
||||
# Bump MCP server (default - all commits except helm/astrolabe scopes)
|
||||
# Bump MCP server (default - all commits except helm scope)
|
||||
echo "Checking MCP server for version bump..."
|
||||
|
||||
# Get the most recent MCP tag
|
||||
@@ -83,9 +83,9 @@ jobs:
|
||||
commit_range="${last_mcp_tag}..HEAD"
|
||||
fi
|
||||
|
||||
# Count conventional commits that are NOT scoped to helm or astrolabe
|
||||
# Count conventional commits that are NOT scoped to helm
|
||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
||||
{ grep -v "(helm)" || true; } | wc -l)
|
||||
|
||||
MCP_BUMPED=false
|
||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||
@@ -115,14 +115,6 @@ jobs:
|
||||
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"
|
||||
@@ -158,10 +150,6 @@ jobs:
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
astrolabe)
|
||||
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
|
||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_bots: "renovate-bot-cbcoutinho"
|
||||
|
||||
@@ -26,13 +26,13 @@ jobs:
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@0ed5eeaa54d3b0170e79f1ff29996342cf0605f1 # v1.0.40
|
||||
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
@@ -34,18 +34,18 @@ jobs:
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@ jobs:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
@@ -48,32 +48,15 @@ jobs:
|
||||
###### 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
|
||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
|
||||
@@ -4,3 +4,6 @@
|
||||
[submodule "third_party/notes"]
|
||||
path = third_party/notes
|
||||
url = https://github.com/cbcoutinho/notes
|
||||
[submodule "third_party/astrolabe"]
|
||||
path = third_party/astrolabe
|
||||
url = https://github.com/cbcoutinho/astrolabe
|
||||
|
||||
@@ -5,6 +5,76 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.64.3 (2026-02-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #574 fourth review round
|
||||
- address PR #574 third review round
|
||||
- address PR #574 second review round
|
||||
- address PR #574 review comments
|
||||
- wrap raw list returns in response models to produce single TextContent block
|
||||
|
||||
## v0.64.2 (2026-02-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #571 review comments
|
||||
- resolve stale credentials causing astrolabe background sync test failures
|
||||
|
||||
### Refactor
|
||||
|
||||
- enforce PLC0415 (import-outside-top-level) for source code
|
||||
|
||||
## v0.64.1 (2026-02-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||
|
||||
## v0.64.0 (2026-02-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- add self-signed SSL certificate support for Nextcloud connections
|
||||
|
||||
### Fix
|
||||
|
||||
- add type: ignore for caldav ssl_verify_cert parameter
|
||||
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||
|
||||
## v0.63.5 (2026-02-16)
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove stale astrolabe references from commitizen config
|
||||
- extract Astrolabe to separate repository
|
||||
|
||||
## v0.63.4 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## v0.63.3 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## v0.63.2 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## v0.63.1 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## v0.63.0 (2026-01-28)
|
||||
|
||||
### Feat
|
||||
|
||||
+3
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
## Version Management
|
||||
|
||||
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||
This monorepo uses commitizen for version management with **independent versioning** for two components:
|
||||
|
||||
### Components
|
||||
|
||||
@@ -10,7 +10,8 @@ This monorepo uses commitizen for version management with **independent versioni
|
||||
|-----------|-------|--------------|-------------|
|
||||
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||
|
||||
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
|
||||
# Helm chart changes
|
||||
feat(helm): add resource limits
|
||||
docs(helm): update values documentation
|
||||
|
||||
# Astrolabe app changes
|
||||
feat(astrolabe): add dark mode toggle
|
||||
fix(astrolabe): resolve search UI bug
|
||||
```
|
||||
|
||||
**Unscoped commits** default to the MCP server:
|
||||
@@ -40,7 +37,6 @@ feat: add new feature # → MCP server (v0.54.0)
|
||||
#### 1. Make Changes with Scoped Commits
|
||||
|
||||
```bash
|
||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||
git commit -m "feat(helm): add ingress annotations"
|
||||
git commit -m "feat(mcp): add calendar sync"
|
||||
```
|
||||
@@ -58,10 +54,6 @@ git commit -m "feat(mcp): add calendar sync"
|
||||
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||
# → Updates: Chart.yaml:version
|
||||
|
||||
# Bump Astrolabe (reads commits with scope=astrolabe)
|
||||
./scripts/bump-astrolabe.sh
|
||||
# → Creates tag: astrolabe-v0.2.0
|
||||
# → Updates: info.xml, package.json
|
||||
```
|
||||
|
||||
#### 3. Push Tags
|
||||
@@ -76,7 +68,6 @@ Each component maintains its own `CHANGELOG.md`:
|
||||
|
||||
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||
|
||||
### Manual Version Bumps
|
||||
|
||||
@@ -101,7 +92,6 @@ uv run cz --config .cz.toml bump --increment MINOR
|
||||
|
||||
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||
|
||||
### Chart.yaml Version vs appVersion
|
||||
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
+2
-2
@@ -12,12 +12,12 @@
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
|
||||
@@ -2,35 +2,17 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing Astrolabe app for testing..."
|
||||
echo "Installing Astrolabe app from app store..."
|
||||
|
||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||
if [ -d /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
|
||||
if [ -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 "Astrolabe app installed successfully"
|
||||
echo ""
|
||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||
echo " to support testing multiple MCP server deployments."
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.57.26"
|
||||
version = "0.57.72"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
|
||||
@@ -14,6 +14,142 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
|
||||
## nextcloud-mcp-server-0.57.72 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR #571 review comments
|
||||
- resolve stale credentials causing astrolabe background sync test failures
|
||||
|
||||
### Refactor
|
||||
|
||||
- enforce PLC0415 (import-outside-top-level) for source code
|
||||
|
||||
## nextcloud-mcp-server-0.57.69 (2026-02-20)
|
||||
|
||||
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.67 (2026-02-19)
|
||||
|
||||
## nextcloud-mcp-server-0.57.66 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.65 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.64 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.63 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.62 (2026-02-18)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||
|
||||
## nextcloud-mcp-server-0.57.61 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.60 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.59 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.58 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.57 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.56 (2026-02-18)
|
||||
|
||||
## nextcloud-mcp-server-0.57.55 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.54 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.53 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.52 (2026-02-17)
|
||||
|
||||
## nextcloud-mcp-server-0.57.51 (2026-02-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- add self-signed SSL certificate support for Nextcloud connections
|
||||
|
||||
### Fix
|
||||
|
||||
- add type: ignore for caldav ssl_verify_cert parameter
|
||||
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||
|
||||
## nextcloud-mcp-server-0.57.50 (2026-02-16)
|
||||
|
||||
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||
|
||||
### Refactor
|
||||
|
||||
- remove stale astrolabe references from commitizen config
|
||||
- extract Astrolabe to separate repository
|
||||
|
||||
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
||||
|
||||
## nextcloud-mcp-server-0.57.46 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.45 (2026-02-12)
|
||||
|
||||
## nextcloud-mcp-server-0.57.44 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.43 (2026-02-11)
|
||||
|
||||
## nextcloud-mcp-server-0.57.42 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- strip whitespace from category names when splitting
|
||||
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||
|
||||
## nextcloud-mcp-server-0.57.41 (2026-02-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- expand recurring events in date-range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||
|
||||
### Fix
|
||||
|
||||
- use CalDAV time-range filter for calendar date range queries
|
||||
|
||||
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||
|
||||
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||
|
||||
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||
|
||||
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||
|
||||
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **helm**: add backward compatibility for legacy persistence configs
|
||||
|
||||
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||
|
||||
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.16.3
|
||||
version: 1.17.0
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.40.0
|
||||
digest: sha256:d8cbf3eab778b3e28818dd1f9cbd71c99ce968fb2a46880b162f988a59a5fedf
|
||||
generated: "2026-01-30T11:10:10.104463708Z"
|
||||
version: 1.44.0
|
||||
digest: sha256:3a9d071ea6da98ab0508549ce35e7a2db3fc1fd496c13ec7f95f1102378a1ac8
|
||||
generated: "2026-02-20T17:15:49.985311295Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.57.26
|
||||
appVersion: "0.63.0"
|
||||
version: 0.57.72
|
||||
appVersion: "0.64.3"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -27,10 +27,10 @@ annotations:
|
||||
grafana_dashboard_folder: "Nextcloud MCP"
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "1.16.3"
|
||||
version: "1.17.0"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.40.0"
|
||||
version: "1.44.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -118,6 +118,25 @@ ingress:
|
||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||
|
||||
#### Data Storage
|
||||
|
||||
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|-----------|-------------|---------|
|
||||
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||
|
||||
**When to enable persistence:**
|
||||
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||
- Qdrant persistent mode (stores vector database)
|
||||
- Any feature requiring persistent app data
|
||||
|
||||
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||
|
||||
#### MCP Server Configuration
|
||||
|
||||
| Parameter | Description | Default |
|
||||
|
||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||
{{- end }}
|
||||
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||
|
||||
================================================================================
|
||||
DEPRECATION WARNING
|
||||
================================================================================
|
||||
|
||||
You are using deprecated persistence configuration that will be removed in a
|
||||
future release. Your deployment will continue to work, but please migrate to
|
||||
the new unified dataStorage configuration.
|
||||
|
||||
Deprecated settings detected:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.* (currently enabled)
|
||||
{{- end }}
|
||||
|
||||
To migrate, update your values.yaml:
|
||||
|
||||
dataStorage:
|
||||
enabled: true
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
size: {{ .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
# storageClass: "" # Optional: specify storage class
|
||||
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||
|
||||
After migrating, remove the deprecated settings:
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
- auth.multiUserBasic.persistence.enabled
|
||||
- auth.multiUserBasic.persistence.size
|
||||
- auth.multiUserBasic.persistence.storageClass
|
||||
- auth.multiUserBasic.persistence.accessMode
|
||||
{{- end }}
|
||||
{{- if $legacyQdrant }}
|
||||
- qdrant.localPersistence.enabled
|
||||
- qdrant.localPersistence.size
|
||||
- qdrant.localPersistence.storageClass
|
||||
- qdrant.localPersistence.accessMode
|
||||
{{- end }}
|
||||
|
||||
================================================================================
|
||||
{{- end }}
|
||||
|
||||
For more information and documentation:
|
||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||
|
||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the PVC to use for /app/data storage
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||
{{- if .Values.dataStorage.existingClaim }}
|
||||
{{- .Values.dataStorage.existingClaim }}
|
||||
{{- else }}
|
||||
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Determine if data storage PVC should be enabled (backward compatible)
|
||||
Checks new dataStorage.enabled OR legacy persistence configs
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||
{{- if .Values.dataStorage.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||
true
|
||||
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy multi-user-basic persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Check if legacy qdrant persistence config is being used
|
||||
*/}}
|
||||
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||
true
|
||||
{{- else -}}
|
||||
false
|
||||
{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Return the MCP server port
|
||||
*/}}
|
||||
|
||||
@@ -286,14 +286,8 @@ spec:
|
||||
- name: oauth-storage
|
||||
mountPath: /app/.oauth
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
mountPath: /app/data
|
||||
{{- end }}
|
||||
{{- with .Values.volumeMounts }}
|
||||
{{- toYaml . | nindent 12 }}
|
||||
{{- end }}
|
||||
@@ -305,15 +299,12 @@ spec:
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
||||
- name: token-storage
|
||||
- name: data-storage
|
||||
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
||||
{{- end }}
|
||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
||||
- name: qdrant-data
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
||||
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||
{{- else }}
|
||||
emptyDir: {}
|
||||
{{- end }}
|
||||
{{- with .Values.volumes }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -16,38 +16,34 @@ spec:
|
||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
||||
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||
{{- $size := .Values.dataStorage.size }}
|
||||
{{- if $legacyMultiUserBasic }}
|
||||
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||
{{- else if $legacyQdrant }}
|
||||
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||
{{- end }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-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 }}
|
||||
- {{ $accessMode }}
|
||||
{{- if $storageClass }}
|
||||
storageClassName: {{ $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) }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
||||
labels:
|
||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||
spec:
|
||||
accessModes:
|
||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
||||
storage: {{ $size }}
|
||||
{{- end }}
|
||||
|
||||
@@ -139,6 +139,27 @@ auth:
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# Data Storage Configuration
|
||||
# Persistent volume for /app/data directory
|
||||
# Used for: token databases, qdrant persistent storage, and any app data
|
||||
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||
dataStorage:
|
||||
# Enable persistent storage for /app/data
|
||||
# Set to true when using:
|
||||
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||
# - Qdrant persistent mode (stores vector database)
|
||||
# - Any feature requiring persistent app data
|
||||
# Set to false for basic auth without persistence (uses emptyDir)
|
||||
enabled: false
|
||||
# Storage class (leave empty for default)
|
||||
storageClass: ""
|
||||
accessMode: ReadWriteOnce
|
||||
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||
size: 1Gi
|
||||
# Use existing PVC
|
||||
existingClaim: ""
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
# Transport mode (default: streamable-http for SSE)
|
||||
|
||||
+7
-8
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
|
||||
image: docker.io/library/mariadb:lts@sha256:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -19,11 +19,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
||||
image: docker.io/library/redis:alpine@sha256:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
||||
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8080:80
|
||||
@@ -37,7 +37,6 @@ services:
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/opt/apps:ro
|
||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -54,14 +53,14 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:4870c12cd2ca986de501a804b4f506ad3875a0b1874940ba0a2c7f763f1855b2
|
||||
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
|
||||
unstructured:
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:9945a842ba983afcf110053cbcc0df7e4bd09ba9f02aa213824ce3f986713635
|
||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:8002:8000
|
||||
@@ -208,7 +207,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
||||
image: quay.io/keycloak/keycloak:26.5.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -295,7 +294,7 @@ services:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
||||
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -356,45 +356,6 @@ Not applicable. Smithery deployments don't integrate with Astrolabe.
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Background Token Refresh
|
||||
|
||||
The Astrolabe Nextcloud app includes a background job that proactively refreshes OAuth tokens before expiration.
|
||||
|
||||
```
|
||||
Nextcloud Cron Astrolabe MCP Server IdP
|
||||
│ │ │
|
||||
│── Run RefreshUserTokens ───▶│ │
|
||||
│ (every 15 minutes) │ │
|
||||
│ │── Get all user tokens ────▶│
|
||||
│ │ (from preferences) │
|
||||
│ │ │
|
||||
│ [For each user] │ │
|
||||
│ │── Check expiry ───────────▶│
|
||||
│ │ refresh if <50% lifetime │
|
||||
│ │ │
|
||||
│ │── Acquire user lock ──────▶│
|
||||
│ │ (prevent race condition) │
|
||||
│ │ │
|
||||
│ │── Token refresh request ──▶│
|
||||
│ │ grant_type=refresh_token │
|
||||
│ │◀── New tokens ─────────────│
|
||||
│ │ │
|
||||
│ │── Store new tokens ───────▶│
|
||||
│ │ (with issued_at) │
|
||||
│◀── Job complete ────────────│ │
|
||||
```
|
||||
|
||||
**Key characteristics:**
|
||||
- Runs every 15 minutes via Nextcloud cron
|
||||
- Refreshes when <50% of token lifetime remains
|
||||
- Uses locking to prevent race conditions with on-demand refresh
|
||||
- Stores `issued_at` timestamp for accurate lifetime calculation
|
||||
- Batch processing (100 users at a time) for memory efficiency
|
||||
|
||||
**Implementation:** `third_party/astrolabe/lib/BackgroundJob/RefreshUserTokens.php`
|
||||
|
||||
---
|
||||
|
||||
## Configuration Quick Reference
|
||||
|
||||
### Single-User BasicAuth
|
||||
|
||||
+1
-46
@@ -225,52 +225,7 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||
|
||||
### Astrolabe User Setup (Hybrid Mode)
|
||||
|
||||
When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**:
|
||||
|
||||
#### Step 1: OAuth Authorization (Search Access)
|
||||
|
||||
**Purpose**: Allows Astrolabe to call MCP server APIs on the user's behalf.
|
||||
|
||||
**Flow**:
|
||||
1. User opens Astrolabe Personal Settings in Nextcloud
|
||||
2. Clicks "Authorize" button
|
||||
3. Redirected to Astrolabe's OAuth controller (`/apps/astrolabe/oauth/initiate`)
|
||||
4. OAuth controller discovers IdP from MCP server's `/api/v1/status` endpoint
|
||||
5. User authenticates with Identity Provider (Nextcloud OIDC or external IdP)
|
||||
6. Tokens stored in Nextcloud user config (`McpTokenStorage`)
|
||||
7. Astrolabe can now perform semantic searches via MCP API
|
||||
|
||||
**Technical Details**:
|
||||
- Token audience: MCP server
|
||||
- Token storage: Nextcloud app config (`oc_preferences`)
|
||||
- Used for: `/api/v1/search`, `/api/v1/status` (authenticated endpoints)
|
||||
|
||||
#### Step 2: App Password (Background Indexing)
|
||||
|
||||
**Purpose**: Allows MCP server to access Nextcloud content for background sync.
|
||||
|
||||
**Flow**:
|
||||
1. User generates app password in Nextcloud Security settings
|
||||
2. Enters app password in Astrolabe Personal Settings
|
||||
3. App password validated against Nextcloud and stored (encrypted)
|
||||
4. MCP server can now index user's content in the background
|
||||
|
||||
**Technical Details**:
|
||||
- Credential type: Nextcloud app password
|
||||
- Token storage: MCP server's refresh token database
|
||||
- Used for: Background indexing, content sync to vector database
|
||||
|
||||
#### Why Two Credentials?
|
||||
|
||||
| Direction | Auth Method | Purpose |
|
||||
|-----------|-------------|---------|
|
||||
| Astrolabe → MCP Server | OAuth Bearer Token | User searches, settings management |
|
||||
| MCP Server → Nextcloud | BasicAuth (App Password) | Background content indexing |
|
||||
|
||||
The separation ensures:
|
||||
- **Security**: Each credential has limited scope
|
||||
- **Audit Trail**: OAuth tokens identify users; app passwords enable background ops
|
||||
- **User Control**: Users explicitly grant each type of access
|
||||
For Astrolabe-specific user setup instructions in hybrid mode, see the [Astrolabe documentation](https://github.com/cbcoutinho/astrolabe/blob/master/docs/user-setup-hybrid-mode.md).
|
||||
|
||||
### See Also
|
||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||
|
||||
@@ -1,206 +0,0 @@
|
||||
# Introducing Astrolabe: Navigate Your Data Universe in Nextcloud
|
||||
|
||||
Your Nextcloud instance holds years of notes, projects, recipes, contacts, and documents. But when you need to find something, you're stuck typing exact keywords and hoping for the best. Search "car repair" and miss that note titled "Vehicle maintenance tips." Search "meeting agenda" and overlook the calendar event called "Team sync." Traditional keyword search demands that you remember exactly how you wrote things down.
|
||||
|
||||
What if your search could understand what you *mean*, not just what you type?
|
||||
|
||||
Meet **Astrolabe**—a Nextcloud app that brings AI-powered semantic search to your self-hosted cloud. Named after the ancient navigational instrument that helped travelers chart courses by the stars, Astrolabe helps you navigate your personal knowledge by mapping the semantic connections between your documents.
|
||||
|
||||
## The Astrolabe Metaphor
|
||||
|
||||
The astrolabe was one of humanity's most elegant scientific instruments—an analog computer for solving problems related to time and the position of celestial bodies. Its theoretical foundation traces back to **Hipparchus of Nicaea** (c. 190–120 BCE), who discovered the stereographic projection that allows a three-dimensional celestial sphere to be represented on a flat surface. Later Greek scholars like **Theon of Alexandria** and his daughter **Hypatia** refined it into a practical instrument, and during the Islamic Golden Age, astronomers in Baghdad, Damascus, and Cordoba perfected its design and applications.
|
||||
|
||||
For nearly two millennia, astrolabes served astronomers, navigators, scholars, and religious officials across the Greek, Byzantine, Islamic, and medieval European worlds. These instruments allowed users to determine time, find celestial positions, calculate daylight hours, identify constellations, and even determine the direction of Mecca for prayer—all without complex calculations. The astrolabe made the vast complexity of the heavens understandable and navigable.
|
||||
|
||||
**Astrolabe** (the app) does the same for your data. Every document, note, and calendar event becomes a point of light in your personal data universe. The app maps their semantic relationships—their meaning, not just their words—and suddenly the connections become visible. Documents cluster by topic, related ideas sit nearby, and you can navigate this landscape as naturally as medieval scholars once read the stars. Where the original astrolabe projected the celestial sphere onto brass, this one projects your knowledge into explorable semantic space.
|
||||
|
||||
## Semantic Search: Find Meaning, Not Just Keywords
|
||||
|
||||
The core feature of Astrolabe is semantic search. Instead of matching exact keywords, it understands the concepts in your query and finds related content.
|
||||
|
||||
**What this looks like in practice:**
|
||||
|
||||
| You Search For | Traditional Search Finds | Astrolabe Also Finds |
|
||||
|----------------|--------------------------|----------------------|
|
||||
| "car repair" | Documents containing "car repair" | Notes about "vehicle maintenance," "fixing the truck" |
|
||||
| "team planning" | Documents with "team planning" | Calendar events titled "Q2 kickoff," Deck cards about "project roadmap" |
|
||||
| "pasta recipes" | Documents with "pasta recipes" | Notes about "Italian cooking," "homemade noodles," "carbonara tips" |
|
||||
|
||||
This works across multiple Nextcloud apps: Notes, Files (including PDFs with OCR), Deck cards, Calendar events, Contacts, and News/RSS items. One search bar, all your content, understood by meaning.
|
||||
|
||||
### Hybrid Search: Best of Both Worlds
|
||||
|
||||
Sometimes you want exact matches ("PROJ-2024-001"), sometimes you want semantic understanding ("that project from last year about authentication"). Astrolabe's hybrid search combines both approaches:
|
||||
|
||||
- **Semantic search** uses embeddings to find conceptually related content
|
||||
- **BM25 keyword search** finds exact matches and important terms
|
||||
- **Reciprocal Rank Fusion (RRF)** intelligently merges the results
|
||||
|
||||
You can adjust the balance or switch modes entirely depending on your needs.
|
||||
|
||||

|
||||
*Astrolabe results appear alongside traditional search in Nextcloud's unified search bar*
|
||||
|
||||
## Visualize Your Data Universe
|
||||
|
||||
Beyond search, Astrolabe includes an interactive 3D visualization that shows your documents positioned in semantic space. Similar documents cluster together. Topics form constellations. You can rotate, zoom, and explore.
|
||||
|
||||
This isn't just eye candy—it's a practical tool for knowledge discovery:
|
||||
|
||||
- **Find forgotten connections**: Search for your current project and watch as related documents from months ago light up nearby
|
||||
- **Spot topic clusters**: See how your notes naturally group by subject
|
||||
- **Explore the unknown**: Click on points near your search results to discover content you didn't know was related
|
||||
|
||||
The visualization uses Principal Component Analysis (PCA) to project high-dimensional embeddings (768 dimensions) down to 3D space while preserving the relationships between documents. We implemented a lightweight, custom PCA specifically for this—no heavyweight ML libraries required.
|
||||
|
||||

|
||||
*Documents cluster by semantic similarity. The query point (red) shows your search, and related documents cluster nearby*
|
||||
|
||||
## Power Your AI Agents
|
||||
|
||||
Astrolabe isn't just for humans—it's for your AI assistants too.
|
||||
|
||||
The backend runs a **Model Context Protocol (MCP)** server, which means AI tools like Claude Desktop, Cursor, or custom agents can connect directly to your Nextcloud data. Your AI assistant can:
|
||||
|
||||
- Search your notes semantically ("Find everything related to the Kubernetes migration")
|
||||
- Retrieve document content for context
|
||||
- Get AI-generated answers with citations from your documents (RAG)
|
||||
|
||||
The critical point: **your data never leaves your infrastructure**. The MCP server runs on your hardware. Your AI assistant sends queries, the server returns results, and you maintain full control. No documents uploaded to third-party services.
|
||||
|
||||
### Retrieval-Augmented Generation (RAG)
|
||||
|
||||
Ask a question, and Astrolabe can retrieve relevant documents and have your AI synthesize an answer—complete with citations:
|
||||
|
||||
```
|
||||
You: "What were the main issues we had deploying to production last month?"
|
||||
|
||||
Astrolabe finds: 3 relevant notes, 2 Deck cards, 1 calendar event
|
||||
|
||||
AI generates: "Based on your documents, there were three main issues:
|
||||
1. Database migration timeout (see Note: 'Prod deploy 2024-01-15')
|
||||
2. SSL certificate renewal (see Deck card: 'Ops Tasks')
|
||||
3. Resource limits on the new pods (see Note: 'K8s troubleshooting')
|
||||
```
|
||||
|
||||
This uses MCP's sampling capability—the server doesn't run its own LLM. Instead, it asks your client's AI to generate the response. You choose the model, you control the costs.
|
||||
|
||||
## Under the Hood
|
||||
|
||||
For the technically curious, here's how Astrolabe works:
|
||||
|
||||
### Embedding Providers
|
||||
|
||||
Astrolabe supports multiple backends for generating semantic embeddings:
|
||||
|
||||
- **Amazon Bedrock**: Enterprise-grade, Titan embeddings
|
||||
- **OpenAI**: Direct OpenAI API or compatible endpoints (including GitHub Models)
|
||||
- **Ollama**: Self-hosted, privacy-focused, runs entirely on your hardware
|
||||
|
||||
The system auto-detects available providers based on environment variables and falls back gracefully. Deploy Ollama on your server for full privacy, or use Bedrock for enterprise scale—same codebase, zero code changes.
|
||||
|
||||
### Background Indexing
|
||||
|
||||
Documents are indexed automatically via webhooks. When you create or edit a note, Nextcloud fires an event, and the MCP server processes it in the background. No manual sync required.
|
||||
|
||||
The indexing pipeline:
|
||||
1. **Scanner** detects changes via ETags and modification timestamps
|
||||
2. **Queue** manages backpressure (up to 10k pending documents)
|
||||
3. **Worker pool** processes embeddings concurrently (configurable, default 3 workers)
|
||||
4. **Qdrant** stores vectors for fast similarity search
|
||||
|
||||
### Lightweight by Design
|
||||
|
||||
We deliberately avoided heavyweight dependencies:
|
||||
|
||||
- **Custom PCA**: No scikit-learn, just efficient eigendecomposition
|
||||
- **In-process async**: No separate message queues or worker processes—just anyio TaskGroups
|
||||
- **Plugin architecture**: New apps (Notes, Calendar, etc.) are simple scanner/processor implementations
|
||||
|
||||
This means Astrolabe runs comfortably alongside your Nextcloud on modest hardware.
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌─────────────┐ ┌─────────┐
|
||||
│ Nextcloud │────▶│ MCP Server │────▶│ Qdrant │
|
||||
│ (Astrolabe) │◀────│ (Python) │◀────│ (Vectors)│
|
||||
└──────────────┘ └─────────────┘ └─────────┘
|
||||
│ │
|
||||
│ OAuth/Token │ Embeddings
|
||||
▼ ▼
|
||||
┌────────┐ ┌──────────┐
|
||||
│ User │ │ Ollama/ │
|
||||
│Browser │ │ Bedrock │
|
||||
└────────┘ └──────────┘
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Requirements
|
||||
|
||||
- Nextcloud 31 or 32
|
||||
- MCP server instance (Docker recommended)
|
||||
- Vector database (Qdrant, included in Docker setup)
|
||||
- Embedding provider (Ollama for self-hosted, or cloud options)
|
||||
|
||||
### Quick Setup
|
||||
|
||||
1. **Install the Astrolabe app** from the Nextcloud App Store (or manually)
|
||||
|
||||
2. **Start the MCP server** (Docker Compose makes this easy):
|
||||
```bash
|
||||
docker compose up -d mcp qdrant ollama
|
||||
```
|
||||
|
||||
3. **Configure the connection** in your Nextcloud `config.php`:
|
||||
```php
|
||||
'astrolabe' => [
|
||||
'mcp_server_url' => 'http://localhost:8000',
|
||||
],
|
||||
```
|
||||
|
||||
4. **Authorize access** in Settings → Personal → Astrolabe
|
||||
|
||||
5. **Start searching** using Nextcloud's unified search bar
|
||||
|
||||
For detailed setup instructions, including OAuth configuration and embedding provider options, see the [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server).
|
||||
|
||||
## What Can You Index?
|
||||
|
||||
Astrolabe currently supports:
|
||||
|
||||
| App | What Gets Indexed |
|
||||
|-----|-------------------|
|
||||
| **Notes** | Full text and metadata |
|
||||
| **Files** | PDFs (with OCR), DOCX, text files |
|
||||
| **Deck** | Card titles and descriptions |
|
||||
| **Calendar** | Event titles, descriptions, and details |
|
||||
| **Contacts** | Names, notes, and contact information |
|
||||
| **News** | RSS/Atom feed articles |
|
||||
|
||||
Each result shows the document type, relevance score, and a direct link to the source. For large documents, it shows which chunk (section) matched.
|
||||
|
||||

|
||||
*Click a result to see the matching chunk in context*
|
||||
|
||||
## Who Is This For?
|
||||
|
||||
**Researchers and students**: Find all notes related to your thesis topic, even when you used different terminology across semesters. Discover connections between papers you read months apart.
|
||||
|
||||
**Teams and organizations**: Surface institutional knowledge that would otherwise stay buried. New team members can search for concepts instead of knowing exactly what to look for.
|
||||
|
||||
**Developers**: Connect your AI coding assistant to your Nextcloud. Give it access to project notes, meeting records, and documentation without copy-pasting context.
|
||||
|
||||
**Personal knowledge managers**: Discover forgotten documents related to your current work. Watch your knowledge base evolve over time through the visualization.
|
||||
|
||||
## Try It Out
|
||||
|
||||
Astrolabe is open source (AGPL) and ready to use. Your data universe has been waiting in the dark—it's time to turn on the lights.
|
||||
|
||||
- **Install**: [Nextcloud App Store](https://apps.nextcloud.com/apps/astrolabe)
|
||||
- **Source**: [GitHub](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
||||
- **Documentation**: [Setup Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/tree/master/docs)
|
||||
- **Issues**: [Report bugs or request features](https://github.com/cbcoutinho/nextcloud-mcp-server/issues)
|
||||
|
||||
---
|
||||
|
||||
*Astrolabe is maintained by [Chris Coutinho](https://github.com/cbcoutinho). Contributions welcome.*
|
||||
+52
-22
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
||||
|
||||
---
|
||||
|
||||
## SSL/TLS Configuration (Optional)
|
||||
|
||||
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
|
||||
|
||||
### Custom CA Bundle (Recommended)
|
||||
|
||||
Point the server at your CA certificate file:
|
||||
|
||||
```dotenv
|
||||
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||
```
|
||||
|
||||
With Docker, mount the certificate as a read-only volume:
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
|
||||
-e NEXTCLOUD_HOST=https://nextcloud.local \
|
||||
--env-file .env \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Disable Verification (Development Only)
|
||||
|
||||
> [!WARNING]
|
||||
> Disabling TLS verification is insecure. Only use this for local development or testing.
|
||||
|
||||
```dotenv
|
||||
NEXTCLOUD_VERIFY_SSL=false
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
|
||||
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
|
||||
|
||||
### Scope
|
||||
|
||||
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
|
||||
|
||||
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
|
||||
- OIDC discovery and token endpoints
|
||||
- OAuth client registration (DCR)
|
||||
- Health checks
|
||||
|
||||
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
|
||||
|
||||
---
|
||||
|
||||
## Semantic Search Configuration (Optional)
|
||||
|
||||
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||
@@ -531,28 +583,6 @@ docker-compose up
|
||||
|
||||
---
|
||||
|
||||
## Astrolabe Internal URL
|
||||
|
||||
The Astrolabe Nextcloud app may need to make internal HTTP requests to the local web server (e.g., for OAuth token refresh). By default, it uses `http://localhost` which works for standard Docker containers where PHP and Apache run together.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `astrolabe_internal_url` | Internal URL for server-to-server requests within container | `http://localhost` |
|
||||
|
||||
**When to configure:**
|
||||
- Custom container setups where the internal web server is not on `localhost:80`
|
||||
- Kubernetes deployments with service discovery
|
||||
- Multi-container setups with separate web server containers
|
||||
|
||||
**Example (Nextcloud config.php):**
|
||||
```php
|
||||
'astrolabe_internal_url' => 'http://web-server.internal:8080',
|
||||
```
|
||||
|
||||
**Note:** This is for internal PHP-to-Apache requests, NOT for external client URLs. The default (`http://localhost`) works for standard Docker containers where PHP and Apache run together.
|
||||
|
||||
---
|
||||
|
||||
## Loading Environment Variables
|
||||
|
||||
After creating your `.env` file, load the environment variables:
|
||||
|
||||
@@ -11,7 +11,7 @@ This guide explains how to enable and disable webhooks for vector sync in each M
|
||||
Before enabling webhooks, ensure:
|
||||
|
||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
||||
2. **Astrolabe app** installed in Nextcloud (provides settings UI and credentials API)
|
||||
2. **[Astrolabe app](https://github.com/cbcoutinho/astrolabe)** installed in Nextcloud (provides settings UI and credentials API)
|
||||
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||
4. **Vector sync enabled** on the MCP server
|
||||
|
||||
@@ -261,24 +261,6 @@ php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "
|
||||
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
|
||||
```
|
||||
|
||||
## Webhook Presets (via Astrolabe UI)
|
||||
|
||||
The Astrolabe app provides preset webhook configurations that can be enabled/disabled via the Admin settings UI:
|
||||
|
||||
| Preset | Events Covered |
|
||||
|--------|----------------|
|
||||
| `notes_sync` | File create/update/delete for .md files |
|
||||
| `calendar_sync` | Calendar object events |
|
||||
| `tables_sync` | Tables row events |
|
||||
| `forms_sync` | Forms submission events |
|
||||
| `files_sync` | All file events (optional, high volume) |
|
||||
|
||||
**Enable Presets:**
|
||||
1. Navigate to **Nextcloud Settings → Astrolabe** (Admin settings)
|
||||
2. Toggle desired presets in "Webhook Configuration"
|
||||
|
||||
**Note:** Presets require the MCP server's management API to be accessible. The API uses OAuth bearer tokens from the user's session.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Webhook Authentication
|
||||
@@ -327,7 +309,7 @@ SELECT * FROM oc_webhook_listeners;
|
||||
-- Check OAuth clients
|
||||
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
||||
|
||||
-- Check user credentials in Astrolabe
|
||||
-- Check user credentials stored by Astrolabe app
|
||||
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||
```
|
||||
|
||||
|
||||
+13
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
|
||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||
|
||||
# ===== SSL/TLS =====
|
||||
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
|
||||
#
|
||||
# Disable TLS certificate verification (insecure, development only):
|
||||
#NEXTCLOUD_VERIFY_SSL=false
|
||||
#
|
||||
# Use a custom CA bundle (path to PEM file):
|
||||
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||
#
|
||||
# Docker example: mount the CA bundle as a volume
|
||||
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
|
||||
|
||||
# ===== SECURITY & ADVANCED =====
|
||||
# Cookie security (browser UI)
|
||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||
|
||||
@@ -20,9 +20,15 @@ import time
|
||||
from importlib.metadata import version
|
||||
from typing import Any
|
||||
|
||||
from qdrant_client.models import Filter
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -196,16 +202,12 @@ async def get_server_status(request: Request) -> JSONResponse:
|
||||
# Public endpoint - no authentication required
|
||||
|
||||
# Get configuration
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Calculate uptime
|
||||
uptime_seconds = int(time.time() - _server_start_time)
|
||||
|
||||
# Determine auth mode using proper mode detection
|
||||
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
||||
|
||||
mode = detect_auth_mode(settings)
|
||||
|
||||
# Map deployment mode to auth_mode for API response
|
||||
@@ -266,8 +268,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
|
||||
"""
|
||||
# Public endpoint - no authentication required
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
@@ -299,11 +299,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection, excluding placeholders
|
||||
@@ -375,8 +370,6 @@ async def get_user_session(request: Request) -> JSONResponse:
|
||||
# Check if offline access is enabled
|
||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
enable_offline_access = settings.enable_offline_access
|
||||
|
||||
|
||||
@@ -15,16 +15,16 @@ import logging
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -156,7 +156,7 @@ def _extract_basic_auth(
|
||||
return username, password, None
|
||||
|
||||
|
||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
async def _get_app_password_storage(request: Request) -> RefreshTokenStorage:
|
||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||
|
||||
Checks app.state.storage first, then falls back to creating from environment.
|
||||
@@ -168,8 +168,6 @@ async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
||||
Returns:
|
||||
Initialized RefreshTokenStorage instance
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = getattr(request.app.state, "storage", None)
|
||||
|
||||
if not storage:
|
||||
@@ -200,8 +198,6 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
- Only the user who owns the password can provision it
|
||||
- Rate limited to prevent brute-force attacks
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
@@ -252,7 +248,9 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
|
||||
# Validate app password against Nextcloud
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
async with nextcloud_httpx_client(
|
||||
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||
) as client:
|
||||
# Use OCS API to verify credentials
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
@@ -360,8 +358,6 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
||||
|
||||
Requires BasicAuth with the user's credentials.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
# Get user_id from path
|
||||
path_user_id = request.path_params.get("user_id")
|
||||
if not path_user_id:
|
||||
@@ -380,7 +376,9 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
||||
nextcloud_host = settings.nextcloud_host
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
||||
async with nextcloud_httpx_client(
|
||||
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||
) as client:
|
||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||
response = await client.get(
|
||||
test_url,
|
||||
|
||||
@@ -11,13 +11,10 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
||||
|
||||
import base64
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
import pymupdf
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -29,6 +26,17 @@ from nextcloud_mcp_server.api.management import (
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,8 +76,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
@@ -144,12 +150,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
@@ -254,17 +254,9 @@ async def unified_search(request: Request) -> JSONResponse:
|
||||
# Optional PCA coordinates
|
||||
if include_pca and len(paginated_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
@@ -305,8 +297,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
|
||||
Requires OAuth bearer token for user filtering.
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return JSONResponse(
|
||||
@@ -354,12 +344,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
if fusion not in valid_fusions:
|
||||
fusion = "rrf"
|
||||
|
||||
# Execute search using the appropriate algorithm
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
|
||||
# Select search algorithm
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
@@ -428,18 +412,10 @@ async def vector_search(request: Request) -> JSONResponse:
|
||||
# Compute PCA coordinates for visualization using shared function
|
||||
if include_pca and len(all_results) >= 2:
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.visualization import (
|
||||
compute_pca_coordinates,
|
||||
)
|
||||
|
||||
# Get query embedding from search algorithm or generate it
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
else:
|
||||
from nextcloud_mcp_server.embedding.service import (
|
||||
get_embedding_service,
|
||||
)
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
|
||||
@@ -549,9 +525,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Initialize authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
@@ -581,14 +554,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
||||
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
@@ -735,8 +700,6 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Download PDF via WebDAV using user's token
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
async with NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=token, username=user_id
|
||||
) as nc_client:
|
||||
|
||||
@@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
||||
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse
|
||||
|
||||
@@ -19,6 +18,9 @@ from nextcloud_mcp_server.api.management import (
|
||||
extract_bearer_token,
|
||||
validate_token_and_get_user,
|
||||
)
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -57,7 +59,7 @@ async def get_installed_apps(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -114,8 +116,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get Bearer token from request
|
||||
token = extract_bearer_token(request)
|
||||
if not token:
|
||||
@@ -129,7 +129,7 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -179,8 +179,6 @@ async def create_webhook(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Parse request body
|
||||
body = await request.json()
|
||||
event = body.get("event")
|
||||
@@ -210,7 +208,7 @@ async def create_webhook(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
@@ -255,8 +253,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
||||
)
|
||||
|
||||
try:
|
||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||
|
||||
# Get webhook_id from path parameter
|
||||
webhook_id = request.path_params.get("webhook_id")
|
||||
if not webhook_id:
|
||||
@@ -286,7 +282,7 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
||||
raise ValueError("Nextcloud host not configured")
|
||||
|
||||
# Create authenticated HTTP client
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
|
||||
+77
-94
@@ -10,22 +10,17 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional, cast
|
||||
from urllib.parse import urlparse
|
||||
from typing import Optional, cast
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import anyio
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
|
||||
import click
|
||||
import httpx
|
||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||
from mcp.server.auth.settings import AuthSettings
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.server.transport_security import TransportSecuritySettings
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from pydantic import AnyHttpUrl
|
||||
from starlette.applications import Starlette
|
||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||
@@ -36,6 +31,23 @@ from starlette.staticfiles import StaticFiles
|
||||
from starlette.types import ASGIApp, Receive, Send
|
||||
from starlette.types import Scope as StarletteScope
|
||||
|
||||
from nextcloud_mcp_server.api import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
vector_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth import (
|
||||
InsufficientScopeError,
|
||||
discover_all_scopes,
|
||||
@@ -43,7 +55,38 @@ from nextcloud_mcp_server.auth import (
|
||||
has_required_scopes,
|
||||
is_jwt_token,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login,
|
||||
oauth_login_callback,
|
||||
oauth_logout,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_authorize,
|
||||
oauth_authorize_nextcloud,
|
||||
oauth_callback,
|
||||
oauth_callback_nextcloud,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
@@ -58,6 +101,7 @@ from nextcloud_mcp_server.config_validators import (
|
||||
)
|
||||
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.http import nextcloud_httpx_client
|
||||
from nextcloud_mcp_server.observability import (
|
||||
ObservabilityMiddleware,
|
||||
setup_metrics,
|
||||
@@ -81,6 +125,11 @@ from nextcloud_mcp_server.server import (
|
||||
)
|
||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
oauth_processor_task,
|
||||
user_manager_task,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
HTTPXClientInstrumentor().instrument()
|
||||
@@ -105,7 +154,7 @@ def initialize_document_processors():
|
||||
if "unstructured" in config["processors"]:
|
||||
unst_config = config["processors"]["unstructured"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
||||
from nextcloud_mcp_server.document_processors.unstructured import ( # noqa: PLC0415
|
||||
UnstructuredProcessor,
|
||||
)
|
||||
|
||||
@@ -126,7 +175,7 @@ def initialize_document_processors():
|
||||
if "tesseract" in config["processors"]:
|
||||
tess_config = config["processors"]["tesseract"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.tesseract import (
|
||||
from nextcloud_mcp_server.document_processors.tesseract import ( # noqa: PLC0415
|
||||
TesseractProcessor,
|
||||
)
|
||||
|
||||
@@ -144,7 +193,7 @@ def initialize_document_processors():
|
||||
if "pymupdf" in config["processors"]:
|
||||
pymupdf_config = config["processors"]["pymupdf"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.pymupdf import (
|
||||
from nextcloud_mcp_server.document_processors.pymupdf import ( # noqa: PLC0415
|
||||
PyMuPDFProcessor,
|
||||
)
|
||||
|
||||
@@ -164,7 +213,7 @@ def initialize_document_processors():
|
||||
if "custom" in config["processors"]:
|
||||
custom_config = config["processors"]["custom"]
|
||||
try:
|
||||
from nextcloud_mcp_server.document_processors.custom_http import (
|
||||
from nextcloud_mcp_server.document_processors.custom_http import ( # noqa: PLC0415
|
||||
CustomHTTPProcessor,
|
||||
)
|
||||
|
||||
@@ -430,8 +479,6 @@ class SmitheryConfigMiddleware:
|
||||
) -> None:
|
||||
if scope["type"] == "http":
|
||||
# Extract config from query parameters
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||
params = parse_qs(query_string)
|
||||
|
||||
@@ -506,8 +553,6 @@ async def load_oauth_client_credentials(
|
||||
|
||||
# Try loading from SQLite storage
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
@@ -558,9 +603,6 @@ async def load_oauth_client_credentials(
|
||||
logger.info(f"Requesting token type: {token_type}")
|
||||
|
||||
# Ensure OAuth client in SQLite storage
|
||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
@@ -624,8 +666,6 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
||||
)
|
||||
|
||||
# Initialize persistent storage (for webhook tracking and future features)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
||||
@@ -690,7 +730,7 @@ async def setup_oauth_config():
|
||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||
|
||||
# Perform OIDC discovery
|
||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||
async with nextcloud_httpx_client(follow_redirects=True) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -755,10 +795,6 @@ async def setup_oauth_config():
|
||||
refresh_token_storage = None
|
||||
if enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.storage import (
|
||||
RefreshTokenStorage,
|
||||
)
|
||||
|
||||
# Validate encryption key before initializing
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
@@ -880,8 +916,6 @@ async def setup_oauth_config():
|
||||
oauth_client = None
|
||||
if enable_offline_access and refresh_token_storage and is_external_idp:
|
||||
# For external IdP mode, create generic OIDC client for token operations
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
|
||||
# since this client is used for backend token operations (exchange, refresh)
|
||||
@@ -994,7 +1028,7 @@ async def setup_oauth_config_for_multi_user_basic(
|
||||
|
||||
# Perform OIDC discovery
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
async with nextcloud_httpx_client(
|
||||
timeout=30.0, follow_redirects=True
|
||||
) as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
@@ -1076,8 +1110,6 @@ async def setup_oauth_config_for_multi_user_basic(
|
||||
refresh_token_storage = None
|
||||
if settings.enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
@@ -1543,8 +1575,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
else:
|
||||
# BasicAuth mode - initialize storage for webhook management
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
basic_auth_storage = RefreshTokenStorage.from_env()
|
||||
await basic_auth_storage.initialize()
|
||||
logger.info("Initialized refresh token storage for webhook management")
|
||||
@@ -1652,7 +1682,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
@@ -1744,12 +1773,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
||||
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
||||
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||
oauth_processor_task,
|
||||
user_manager_task,
|
||||
)
|
||||
|
||||
# Get nextcloud_host (from settings - already validated)
|
||||
nextcloud_host_for_sync = settings.nextcloud_host
|
||||
if not nextcloud_host_for_sync:
|
||||
@@ -1814,7 +1837,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
|
||||
# Initialize Qdrant collection before starting background tasks
|
||||
logger.info("Initializing Qdrant collection...")
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
try:
|
||||
await get_qdrant_client() # Triggers collection creation if needed
|
||||
@@ -1825,6 +1847,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||
) from e
|
||||
|
||||
# Clean up stale app passwords at startup (BasicAuth mode only)
|
||||
if not oauth_enabled:
|
||||
try:
|
||||
removed = await token_storage.cleanup_invalid_app_passwords(
|
||||
nextcloud_host=nextcloud_host_for_sync
|
||||
)
|
||||
if removed:
|
||||
logger.info(
|
||||
f"Cleaned up {len(removed)} stale app password(s): {removed}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"App password cleanup failed (non-fatal): {e}")
|
||||
|
||||
# Initialize shared state
|
||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||
max_buffer_size=settings.vector_sync_queue_max_size
|
||||
@@ -1975,7 +2010,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Try to connect to Nextcloud
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=2.0) as client:
|
||||
response = await client.get(f"{nextcloud_host}/status.php")
|
||||
duration = time.time() - start_time
|
||||
if response.status_code == 200:
|
||||
@@ -2112,24 +2147,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||
)
|
||||
if enable_management_apis:
|
||||
from nextcloud_mcp_server.api import (
|
||||
create_webhook,
|
||||
delete_app_password,
|
||||
delete_webhook,
|
||||
get_app_password_status,
|
||||
get_chunk_context,
|
||||
get_installed_apps,
|
||||
get_pdf_preview,
|
||||
get_server_status,
|
||||
get_user_session,
|
||||
get_vector_sync_status,
|
||||
list_webhooks,
|
||||
provision_app_password,
|
||||
revoke_user_access,
|
||||
unified_search,
|
||||
vector_search,
|
||||
)
|
||||
|
||||
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
||||
routes.append(
|
||||
Route(
|
||||
@@ -2237,8 +2254,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
f"OAuth provisioning routes enabled for mode: {mode.value} "
|
||||
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
|
||||
)
|
||||
# Import OAuth routes (ADR-004 Progressive Consent)
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
def oauth_protected_resource_metadata(request):
|
||||
"""RFC 9728 Protected Resource Metadata endpoint.
|
||||
@@ -2300,12 +2315,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
)
|
||||
|
||||
# Add unified OAuth callback endpoint supporting both flows
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
oauth_authorize_nextcloud,
|
||||
oauth_callback,
|
||||
oauth_callback_nextcloud,
|
||||
)
|
||||
|
||||
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
|
||||
logger.info(
|
||||
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
|
||||
@@ -2334,8 +2343,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
|
||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||
if oauth_enabled:
|
||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
||||
|
||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||
|
||||
@@ -2343,12 +2350,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
||||
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
|
||||
if oauth_provisioning_available:
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login,
|
||||
oauth_login_callback,
|
||||
oauth_logout,
|
||||
)
|
||||
|
||||
routes.append(
|
||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
||||
)
|
||||
@@ -2371,24 +2372,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||
# These require session authentication, so we wrap them in a separate app
|
||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
revoke_session,
|
||||
user_info_html,
|
||||
vector_sync_status_fragment,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.viz_routes import (
|
||||
chunk_context_endpoint,
|
||||
vector_visualization_html,
|
||||
vector_visualization_search,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||
disable_webhook_preset,
|
||||
enable_webhook_preset,
|
||||
webhook_management_pane,
|
||||
)
|
||||
|
||||
# Create a separate Starlette app for browser routes that need session auth
|
||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||
browser_routes = [
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
||||
# Discover token endpoint
|
||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||
discovery_resp = await client.get(discovery_url)
|
||||
discovery_resp.raise_for_status()
|
||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
||||
token = await self.get_access_token()
|
||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
async with nextcloud_httpx_client() as client:
|
||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||
|
||||
response = await client.get(
|
||||
|
||||
@@ -11,6 +11,7 @@ import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
@@ -22,6 +23,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_query_idp_userinfo,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -142,7 +145,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -151,8 +154,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
# Replace internal Docker hostname with public URL
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
@@ -286,7 +287,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
oauth_client.token_endpoint,
|
||||
data=token_params,
|
||||
@@ -296,7 +297,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
else:
|
||||
# Integrated mode (Nextcloud OIDC)
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -314,7 +315,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
if code_verifier:
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data=token_params,
|
||||
|
||||
@@ -10,6 +10,8 @@ import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,7 +134,7 @@ async def register_client(
|
||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||
try:
|
||||
response = await client.post(
|
||||
registration_endpoint,
|
||||
@@ -229,7 +231,7 @@ async def delete_client(
|
||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
||||
async with nextcloud_httpx_client(timeout=30.0) as http_client:
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Prefer RFC 7592 Bearer token authentication
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -161,8 +162,6 @@ class ClientRegistry:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Parse the redirect URI
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(redirect_uri)
|
||||
|
||||
# Check against registered patterns
|
||||
|
||||
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client"""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
||||
self._http_client = nextcloud_httpx_client(timeout=30.0)
|
||||
return self._http_client
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -26,15 +26,18 @@ import secrets
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from urllib.parse import urlencode
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, RedirectResponse
|
||||
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -218,7 +221,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
)
|
||||
|
||||
# Fetch authorization endpoint from discovery
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -227,8 +230,6 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
# Parse internal and authorization endpoint to compare hostnames
|
||||
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -363,8 +364,6 @@ async def oauth_authorize_nextcloud(
|
||||
# Fix internal hostname for browser access
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||
auth_parsed = parse_url(authorization_endpoint)
|
||||
|
||||
@@ -462,7 +461,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
discovery_url = oauth_config.get("discovery_url")
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -482,7 +481,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_params["code_verifier"] = code_verifier
|
||||
|
||||
# Exchange code for tokens
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
response = await http_client.post(
|
||||
token_endpoint,
|
||||
data=token_params,
|
||||
@@ -566,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
</html>
|
||||
"""
|
||||
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
return HTMLResponse(content=success_html, status_code=200)
|
||||
|
||||
|
||||
@@ -632,10 +629,6 @@ async def oauth_callback(request: Request):
|
||||
elif flow_type == "browser":
|
||||
# Browser UI Login - establish browser session for /user/page access
|
||||
logger.info("Routing to browser login flow")
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
oauth_login_callback,
|
||||
)
|
||||
|
||||
return await oauth_login_callback(request)
|
||||
|
||||
else:
|
||||
|
||||
@@ -15,6 +15,7 @@ from mcp.shared.exceptions import McpError
|
||||
from mcp.types import ErrorData
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,8 +67,6 @@ def require_provisioning(func: Callable) -> Callable:
|
||||
|
||||
# Check if we're in token exchange mode - if so, skip provisioning check
|
||||
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||
# Token exchange mode - per-request exchange, no provisioning needed
|
||||
|
||||
@@ -9,6 +9,8 @@ from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.fastmcp import Context
|
||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -132,8 +134,6 @@ def require_scopes(*required_scopes: str):
|
||||
# Check if offline access is enabled
|
||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
enable_offline_access = settings.enable_offline_access
|
||||
|
||||
|
||||
@@ -34,8 +34,12 @@ from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import aiosqlite
|
||||
import anyio
|
||||
import httpx
|
||||
from anyio import to_thread
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -164,10 +168,6 @@ class RefreshTokenStorage:
|
||||
|
||||
# Run migrations in a worker thread using anyio.to_thread
|
||||
# This allows Alembic to run its own async operations in a separate context
|
||||
from anyio import to_thread
|
||||
|
||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||
|
||||
if not has_alembic:
|
||||
if has_schema:
|
||||
# Stamp existing database without running migrations
|
||||
@@ -1414,6 +1414,69 @@ class RefreshTokenStorage:
|
||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||
return user_ids
|
||||
|
||||
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
|
||||
"""
|
||||
Validate stored app passwords against Nextcloud and remove invalid ones.
|
||||
|
||||
Makes a lightweight OCS request for each stored user to check if credentials
|
||||
are still valid. Removes entries that return 401/403.
|
||||
|
||||
Args:
|
||||
nextcloud_host: Nextcloud base URL
|
||||
|
||||
Returns:
|
||||
List of user IDs whose app passwords were removed
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
user_ids = await self.get_all_app_password_user_ids()
|
||||
if not user_ids:
|
||||
return []
|
||||
|
||||
removed: list[str] = []
|
||||
|
||||
async def _validate_user(user_id: str) -> None:
|
||||
app_password = await self.get_app_password(user_id)
|
||||
if not app_password:
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
auth=httpx.BasicAuth(user_id, app_password),
|
||||
timeout=10.0,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
"/ocs/v2.php/cloud/user",
|
||||
headers={
|
||||
"OCS-APIRequest": "true",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
if response.status_code in (401, 403):
|
||||
logger.info(
|
||||
f"App password for {user_id} is invalid "
|
||||
f"(HTTP {response.status_code}), removing"
|
||||
)
|
||||
await self.delete_app_password(user_id)
|
||||
removed.append(user_id)
|
||||
else:
|
||||
logger.debug(
|
||||
f"App password for {user_id} validated "
|
||||
f"(HTTP {response.status_code})"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not validate app password for {user_id}: {e}")
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
for user_id in user_ids:
|
||||
tg.start_soon(_validate_user, user_id)
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -25,6 +25,8 @@ import jwt
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
"""Get or create HTTP client."""
|
||||
if self._http_client is None:
|
||||
self._http_client = httpx.AsyncClient(
|
||||
self._http_client = nextcloud_httpx_client(
|
||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
@@ -20,6 +20,7 @@ import httpx
|
||||
import jwt
|
||||
|
||||
from ..config import get_settings
|
||||
from ..http import nextcloud_httpx_client
|
||||
from .storage import RefreshTokenStorage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
||||
self.storage: Optional[RefreshTokenStorage] = None
|
||||
|
||||
# Create HTTP client
|
||||
self.http_client = httpx.AsyncClient(
|
||||
self.http_client = nextcloud_httpx_client(
|
||||
timeout=30.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
record_oauth_token_validation,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||
|
||||
# Common components for all modes
|
||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
||||
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||
|
||||
# JWT verification support
|
||||
self.jwks_client: PyJWKClient | None = None
|
||||
|
||||
@@ -13,15 +13,18 @@ import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from httpx import BasicAuth
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup Jinja2 environment for templates
|
||||
@@ -55,8 +58,6 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudC
|
||||
if not all([nextcloud_host, username, password]):
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
from httpx import BasicAuth
|
||||
|
||||
assert nextcloud_host is not None
|
||||
assert username is not None
|
||||
assert password is not None
|
||||
@@ -128,7 +129,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
|
||||
get_qdrant_client,
|
||||
)
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
@@ -257,7 +260,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
@@ -290,7 +293,7 @@ async def _query_idp_userinfo(
|
||||
User info dictionary from IdP, or None if query fails
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
userinfo_uri,
|
||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||
@@ -430,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
# Check if user is admin (for Webhooks tab)
|
||||
is_admin = False
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
|
||||
# Get authenticated Nextcloud client
|
||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||
@@ -470,8 +471,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
# Get Nextcloud host for generating links to apps (used by viz tab)
|
||||
# Use public issuer URL if available (for browser-accessible links),
|
||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
nextcloud_host_for_links = (
|
||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
||||
|
||||
@@ -18,16 +18,22 @@ from pathlib import Path
|
||||
import anyio
|
||||
import numpy as np
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
from nextcloud_mcp_server.vector.pca import PCA
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
@@ -137,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
# Get authenticated HTTP client from session
|
||||
# In BasicAuth mode: uses username/password from session
|
||||
# In OAuth mode: uses access token from session
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
|
||||
with trace_operation("vector_viz.get_auth_client"):
|
||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||
|
||||
@@ -353,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
@@ -555,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
doc_id_int = int(doc_id)
|
||||
|
||||
# Get authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
# Use context expansion module to fetch chunk with surrounding context
|
||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
@@ -594,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
page_number = None
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
username = request.user.display_name
|
||||
|
||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
||||
get_preset,
|
||||
)
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
assert username is not None and password is not None # Type narrowing
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
timeout=30.0,
|
||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
if not nextcloud_host:
|
||||
raise RuntimeError("Nextcloud host not configured")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
return nextcloud_httpx_client(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
|
||||
@@ -6,6 +6,13 @@ import uvicorn
|
||||
from nextcloud_mcp_server.config import (
|
||||
get_settings,
|
||||
)
|
||||
from nextcloud_mcp_server.migrations import (
|
||||
create_migration,
|
||||
downgrade_database,
|
||||
get_current_revision,
|
||||
show_migration_history,
|
||||
upgrade_database,
|
||||
)
|
||||
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||
|
||||
from .app import get_app
|
||||
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
|
||||
# Use custom database path
|
||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import upgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Upgrading database to revision: {revision}")
|
||||
upgrade_database(database_path, revision)
|
||||
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
|
||||
# Downgrade to base (empty database)
|
||||
$ nextcloud-mcp-server db downgrade --revision base
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import downgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Downgrading database to revision: {revision}")
|
||||
downgrade_database(database_path, revision)
|
||||
@@ -362,8 +365,6 @@ def current(database_path: str):
|
||||
Example:
|
||||
$ nextcloud-mcp-server db current
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import get_current_revision
|
||||
|
||||
try:
|
||||
revision = get_current_revision(database_path)
|
||||
if revision:
|
||||
@@ -397,8 +398,6 @@ def history(database_path: str):
|
||||
Example:
|
||||
$ nextcloud-mcp-server db history
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import show_migration_history
|
||||
|
||||
try:
|
||||
click.echo("Migration history:")
|
||||
show_migration_history(database_path)
|
||||
@@ -421,8 +420,6 @@ def migrate(message: str):
|
||||
|
||||
Note: You must manually edit the generated migration file to add SQL statements.
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import create_migration
|
||||
|
||||
try:
|
||||
click.echo(f"Creating new migration: {message}")
|
||||
create_migration(message)
|
||||
|
||||
@@ -4,7 +4,6 @@ import os
|
||||
from httpx import (
|
||||
AsyncBaseTransport,
|
||||
AsyncClient,
|
||||
AsyncHTTPTransport,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
@@ -13,6 +12,7 @@ from httpx import (
|
||||
)
|
||||
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from ..http import nextcloud_httpx_transport
|
||||
from .calendar import CalendarClient
|
||||
from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
timeout=Timeout(timeout=30, connect=5),
|
||||
)
|
||||
@@ -113,7 +113,7 @@ class NextcloudClient:
|
||||
Returns:
|
||||
NextcloudClient configured with bearer token authentication
|
||||
"""
|
||||
from ..auth import BearerAuth
|
||||
from ..auth import BearerAuth # noqa: PLC0415
|
||||
|
||||
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||
|
||||
@@ -6,12 +6,16 @@ import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import anyio
|
||||
from caldav.async_collection import AsyncCalendar
|
||||
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||
from caldav.async_davclient import AsyncDAVClient
|
||||
from caldav.elements import cdav, dav
|
||||
from httpx import Auth
|
||||
from icalendar import Alarm, Calendar, vRecur
|
||||
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||
from icalendar import Event as ICalEvent
|
||||
from icalendar import Todo as ICalTodo
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
from ..config import get_nextcloud_ssl_verify
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,6 +38,7 @@ class CalendarClient:
|
||||
url=f"{base_url}/remote.php/dav/",
|
||||
username=username,
|
||||
auth=auth,
|
||||
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
|
||||
)
|
||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||
|
||||
@@ -100,8 +105,6 @@ class CalendarClient:
|
||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||
from lxml import etree # type: ignore[import-untyped]
|
||||
|
||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||
<d:prop>
|
||||
@@ -255,18 +258,35 @@ class CalendarClient:
|
||||
"""List events in a calendar within date range."""
|
||||
calendar = self._get_calendar(calendar_name)
|
||||
|
||||
# Get all events using caldav library (now with proper filter)
|
||||
events = await calendar.events()
|
||||
if start_datetime or end_datetime:
|
||||
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||
events = await self._search_events_by_date(
|
||||
calendar, start_datetime, end_datetime
|
||||
)
|
||||
# Expand is only used when both bounds are provided
|
||||
expanded = bool(start_datetime and end_datetime)
|
||||
else:
|
||||
# No date filter — fetch all events
|
||||
events = await calendar.events()
|
||||
expanded = False
|
||||
|
||||
result = []
|
||||
for event in events:
|
||||
await event.load(only_if_unloaded=True)
|
||||
if event.data:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
if expanded:
|
||||
# Server-side expansion: each response resource may contain
|
||||
# multiple VEVENTs (one per recurrence occurrence)
|
||||
for event_dict in self._parse_all_ical_events(event.data):
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
else:
|
||||
event_dict = self._parse_ical_event(event.data)
|
||||
if event_dict:
|
||||
event_dict["href"] = str(event.url)
|
||||
event_dict["etag"] = ""
|
||||
result.append(event_dict)
|
||||
|
||||
if len(result) >= limit:
|
||||
break
|
||||
@@ -274,6 +294,53 @@ class CalendarClient:
|
||||
logger.debug(f"Found {len(result)} events")
|
||||
return result
|
||||
|
||||
async def _search_events_by_date(
|
||||
self,
|
||||
calendar: AsyncCalendar,
|
||||
start_datetime: Optional[dt.datetime] = None,
|
||||
end_datetime: Optional[dt.datetime] = None,
|
||||
) -> list:
|
||||
"""Execute a CalDAV REPORT with time-range filter."""
|
||||
# Ensure naive datetimes are treated as UTC
|
||||
if start_datetime and start_datetime.tzinfo is None:
|
||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||
if end_datetime and end_datetime.tzinfo is None:
|
||||
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||
|
||||
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||
filter_element = cdav.Filter() + outer_comp_filter
|
||||
|
||||
# When both bounds are provided, request server-side expansion of
|
||||
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||
data = cdav.CalendarData()
|
||||
if start_datetime and end_datetime:
|
||||
data += cdav.Expand(start_datetime, end_datetime)
|
||||
|
||||
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||
|
||||
body = etree.tostring(
|
||||
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
assert calendar.client is not None
|
||||
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||
|
||||
# Parse response (same pattern as AsyncCalendar.search)
|
||||
objects = []
|
||||
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||
for href, props in response_data.items():
|
||||
if href == str(calendar.url):
|
||||
continue
|
||||
cal_data = props.get(cdav.CalendarData.tag)
|
||||
if cal_data:
|
||||
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||
objects.append(obj)
|
||||
|
||||
return objects
|
||||
|
||||
async def create_event(
|
||||
self, calendar_name: str, event_data: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
@@ -583,7 +650,7 @@ class CalendarClient:
|
||||
# Add categories
|
||||
categories = event_data.get("categories", "")
|
||||
if categories:
|
||||
event.add("categories", categories.split(","))
|
||||
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||
|
||||
# Add priority and status
|
||||
priority = event_data.get("priority", 5)
|
||||
@@ -633,75 +700,92 @@ class CalendarClient:
|
||||
cal.add_component(event)
|
||||
return cal.to_ical().decode("utf-8")
|
||||
|
||||
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||
"""Extract event data from a single VEVENT component.
|
||||
|
||||
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||
"""
|
||||
event_data: Dict[str, Any] = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract event data."""
|
||||
"""Parse iCalendar text and extract the first event."""
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
event_data = {
|
||||
"uid": str(component.get("uid", "")),
|
||||
"title": str(component.get("summary", "")),
|
||||
"description": str(component.get("description", "")),
|
||||
"location": str(component.get("location", "")),
|
||||
"status": str(component.get("status", "CONFIRMED")),
|
||||
"priority": int(component.get("priority", 5)),
|
||||
"privacy": str(component.get("class", "PUBLIC")),
|
||||
"url": str(component.get("url", "")),
|
||||
}
|
||||
|
||||
# Handle dates
|
||||
dtstart = component.get("dtstart")
|
||||
if dtstart:
|
||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||
dtstart.dt, dt.datetime
|
||||
):
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = True
|
||||
else:
|
||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||
event_data["all_day"] = False
|
||||
|
||||
dtend = component.get("dtend")
|
||||
if dtend:
|
||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
||||
dtend.dt, dt.datetime
|
||||
):
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
else:
|
||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||
|
||||
# Handle categories
|
||||
categories = component.get("categories")
|
||||
if categories:
|
||||
event_data["categories"] = self._extract_categories(categories)
|
||||
|
||||
# Handle recurrence
|
||||
rrule = component.get("rrule")
|
||||
if rrule:
|
||||
event_data["recurring"] = True
|
||||
event_data["recurrence_rule"] = str(rrule)
|
||||
|
||||
# Handle attendees
|
||||
attendees = []
|
||||
for attendee in component.get("attendee", []):
|
||||
if isinstance(attendee, list):
|
||||
attendees.extend(
|
||||
str(a).replace("mailto:", "") for a in attendee
|
||||
)
|
||||
else:
|
||||
attendees.append(str(attendee).replace("mailto:", ""))
|
||||
if attendees:
|
||||
event_data["attendees"] = ",".join(attendees)
|
||||
|
||||
return event_data
|
||||
|
||||
return self._extract_vevent_data(component)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar event: {e}")
|
||||
return None
|
||||
|
||||
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||
"""Parse iCalendar text and extract ALL event occurrences.
|
||||
|
||||
Used with server-side expansion where a single VCALENDAR contains
|
||||
multiple VEVENT components (one per recurrence occurrence).
|
||||
"""
|
||||
results: list[Dict[str, Any]] = []
|
||||
try:
|
||||
cal = Calendar.from_ical(ical_text)
|
||||
for component in cal.walk():
|
||||
if component.name == "VEVENT":
|
||||
results.append(self._extract_vevent_data(component))
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing iCalendar events: {e}")
|
||||
return results
|
||||
|
||||
def _merge_ical_properties(
|
||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||
) -> str:
|
||||
@@ -727,6 +811,50 @@ class CalendarClient:
|
||||
if "url" in event_data:
|
||||
component["URL"] = event_data["url"]
|
||||
|
||||
# Handle categories
|
||||
if "categories" in event_data:
|
||||
categories_str = event_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
elif "CATEGORIES" in component:
|
||||
del component["CATEGORIES"]
|
||||
|
||||
# Handle recurrence rule
|
||||
if "recurrence_rule" in event_data:
|
||||
rrule_str = event_data["recurrence_rule"]
|
||||
if rrule_str:
|
||||
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||
elif "RRULE" in component:
|
||||
del component["RRULE"]
|
||||
|
||||
# Handle attendees
|
||||
if "attendees" in event_data:
|
||||
attendees_str = event_data["attendees"]
|
||||
# Remove all existing attendees first
|
||||
while "ATTENDEE" in component:
|
||||
del component["ATTENDEE"]
|
||||
if attendees_str:
|
||||
for email in attendees_str.split(","):
|
||||
if email.strip():
|
||||
component.add("attendee", f"mailto:{email.strip()}")
|
||||
|
||||
# Handle reminder (VALARM)
|
||||
if "reminder_minutes" in event_data:
|
||||
component.subcomponents = [
|
||||
sub
|
||||
for sub in component.subcomponents
|
||||
if sub.name != "VALARM"
|
||||
]
|
||||
minutes = event_data["reminder_minutes"]
|
||||
if minutes > 0:
|
||||
alarm = Alarm()
|
||||
alarm.add("action", "DISPLAY")
|
||||
alarm.add("description", "Event reminder")
|
||||
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||
component.add_component(alarm)
|
||||
|
||||
# Handle dates
|
||||
if "start_datetime" in event_data:
|
||||
start_str = event_data["start_datetime"]
|
||||
@@ -757,8 +885,6 @@ class CalendarClient:
|
||||
component["DTEND"] = end_dt
|
||||
|
||||
# Update timestamps
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
now = dt.datetime.now(dt.UTC)
|
||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||
component["DTSTAMP"] = vDDDTypes(now)
|
||||
@@ -823,24 +949,18 @@ class CalendarClient:
|
||||
# Due date
|
||||
due = todo_data.get("due", "")
|
||||
if due:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
due_dt = self._ensure_timezone_aware(due)
|
||||
todo.add("due", vDDDTypes(due_dt))
|
||||
|
||||
# Start date
|
||||
dtstart = todo_data.get("dtstart", "")
|
||||
if dtstart:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
start_dt = self._ensure_timezone_aware(dtstart)
|
||||
todo.add("dtstart", vDDDTypes(start_dt))
|
||||
|
||||
# Completed timestamp
|
||||
completed = todo_data.get("completed", "")
|
||||
if completed:
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
completed_dt = self._ensure_timezone_aware(completed)
|
||||
todo.add("completed", vDDDTypes(completed_dt))
|
||||
|
||||
@@ -929,9 +1049,6 @@ class CalendarClient:
|
||||
component["PERCENT-COMPLETE"] = percent_value
|
||||
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
||||
|
||||
# Import vDDDTypes at the beginning for datetime formatting
|
||||
from icalendar import vDDDTypes
|
||||
|
||||
# Handle due date
|
||||
if "due" in todo_data:
|
||||
due_str = todo_data["due"]
|
||||
@@ -960,7 +1077,9 @@ class CalendarClient:
|
||||
if "categories" in todo_data:
|
||||
categories_str = todo_data["categories"]
|
||||
if categories_str:
|
||||
component["CATEGORIES"] = categories_str.split(",")
|
||||
component["CATEGORIES"] = [
|
||||
c.strip() for c in categories_str.split(",")
|
||||
]
|
||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||
|
||||
# Update timestamps
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import quote
|
||||
|
||||
from httpx import Timeout
|
||||
|
||||
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of matching recipe stubs
|
||||
"""
|
||||
# URL encode the query
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_query = quote(query)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of recipe stubs in the category
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_category = quote(category)
|
||||
response = await self._make_request(
|
||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
New category name
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
encoded_old_name = quote(old_name)
|
||||
response = await self._make_request(
|
||||
"PUT",
|
||||
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
|
||||
Returns:
|
||||
List of recipe stubs matching the keywords
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
# Join keywords with commas
|
||||
keywords_str = ",".join(keywords)
|
||||
encoded_keywords = quote(keywords_str)
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
from typing import Any, AsyncIterator, Dict, Optional
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -157,9 +158,6 @@ class NotesClient(BaseNextcloudClient):
|
||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||
)
|
||||
try:
|
||||
# Import here to avoid circular imports
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
await webdav_client.cleanup_old_attachment_directory(
|
||||
note_id=note_id, old_category=old_note.get("category", "")
|
||||
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
|
||||
|
||||
# Clean up attachment directories
|
||||
try:
|
||||
from .webdav import WebDAVClient
|
||||
|
||||
webdav_client = WebDAVClient(self._client, self.username)
|
||||
|
||||
for cat in potential_categories:
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import logging
|
||||
import mimetypes
|
||||
import xml.etree.ElementTree as ET
|
||||
from email.utils import parsedate_to_datetime
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
from httpx import HTTPStatusError
|
||||
|
||||
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
continue
|
||||
|
||||
# Decode href path and extract the file path
|
||||
from urllib.parse import unquote
|
||||
|
||||
href_path = unquote(href_elem.text)
|
||||
# Remove WebDAV prefix to get user-relative path
|
||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
# Parse last modified timestamp
|
||||
last_modified_timestamp = None
|
||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||
last_modified_timestamp = int(dt.timestamp())
|
||||
|
||||
@@ -2,6 +2,7 @@ import logging
|
||||
import logging.config
|
||||
import os
|
||||
import socket
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
@@ -181,6 +182,10 @@ class Settings:
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
nextcloud_ca_bundle: Optional[str] = None
|
||||
|
||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||
@@ -252,9 +257,23 @@ class Settings:
|
||||
log_include_trace_context: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate Qdrant configuration and set defaults."""
|
||||
"""Validate configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Validate SSL/TLS configuration
|
||||
if not self.nextcloud_verify_ssl:
|
||||
logger.warning(
|
||||
"NEXTCLOUD_VERIFY_SSL is disabled. "
|
||||
"TLS certificate verification is turned off for all Nextcloud connections. "
|
||||
"This is insecure and should only be used for development/testing."
|
||||
)
|
||||
if self.nextcloud_ca_bundle:
|
||||
if not os.path.isfile(self.nextcloud_ca_bundle):
|
||||
raise ValueError(
|
||||
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||
)
|
||||
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
|
||||
|
||||
# Ensure mutual exclusivity
|
||||
if self.qdrant_url and self.qdrant_location:
|
||||
raise ValueError(
|
||||
@@ -504,6 +523,11 @@ def get_settings() -> Settings:
|
||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl=(
|
||||
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||
),
|
||||
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||
# ADR-005: Token Audience Validation
|
||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||
@@ -569,3 +593,20 @@ def get_settings() -> Settings:
|
||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||
== "true",
|
||||
)
|
||||
|
||||
|
||||
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
|
||||
"""Return the SSL verification setting for Nextcloud connections.
|
||||
|
||||
Returns:
|
||||
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
|
||||
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
|
||||
- True otherwise (default system CA verification)
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.nextcloud_verify_ssl:
|
||||
return False
|
||||
if settings.nextcloud_ca_bundle:
|
||||
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
|
||||
return ctx
|
||||
return True
|
||||
|
||||
@@ -5,6 +5,10 @@ import logging
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
@@ -80,11 +84,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
from nextcloud_mcp_server.auth.context_helper import (
|
||||
get_client_from_context,
|
||||
get_session_client_from_context,
|
||||
)
|
||||
|
||||
if settings.enable_token_exchange:
|
||||
# Mode 2: Exchange MCP token for Nextcloud token
|
||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||
@@ -131,7 +130,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Centralized HTTP client factory for Nextcloud connections.
|
||||
|
||||
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
|
||||
these factories to ensure consistent SSL/TLS configuration from environment
|
||||
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_nextcloud_ssl_verify
|
||||
|
||||
|
||||
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
|
||||
|
||||
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
|
||||
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
|
||||
takes precedence if explicitly provided.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncClient()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncClient``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
|
||||
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
|
||||
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
|
||||
|
||||
Used by ``NextcloudClient`` which wraps the transport in
|
||||
``AsyncDisableCookieTransport``.
|
||||
|
||||
Args:
|
||||
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
|
||||
|
||||
Returns:
|
||||
Configured ``httpx.AsyncHTTPTransport``.
|
||||
"""
|
||||
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||
return httpx.AsyncHTTPTransport(**kwargs)
|
||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
|
||||
import nextcloud_mcp_server.alembic as alembic_package
|
||||
from alembic import command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -30,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
||||
Returns:
|
||||
Alembic Config object configured for the specified database
|
||||
"""
|
||||
from nextcloud_mcp_server import alembic as alembic_package
|
||||
|
||||
# Use package location (works in both editable and installed modes)
|
||||
if alembic_package.__file__ is None:
|
||||
raise RuntimeError("alembic package __file__ is None")
|
||||
|
||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
||||
status: Optional[str] = Field(
|
||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||
)
|
||||
calendar_name: Optional[str] = Field(
|
||||
None, description="Calendar containing this event"
|
||||
)
|
||||
calendar_display_name: Optional[str] = Field(
|
||||
None, description="Display name of calendar containing this event"
|
||||
)
|
||||
|
||||
|
||||
class CalendarEvent(CalendarEventSummary):
|
||||
|
||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
||||
color: str = Field(description="The created label color")
|
||||
|
||||
|
||||
class ListCardsResponse(BaseResponse):
|
||||
"""Response model for listing deck cards."""
|
||||
|
||||
cards: list[DeckCard] = Field(description="List of deck cards")
|
||||
total: int = Field(description="Total number of cards")
|
||||
|
||||
|
||||
class ListLabelsResponse(BaseResponse):
|
||||
"""Response model for listing deck labels."""
|
||||
|
||||
labels: list[DeckLabel] = Field(description="List of deck labels")
|
||||
total: int = Field(description="Total number of labels")
|
||||
|
||||
|
||||
class LabelOperationResponse(StatusResponse):
|
||||
"""Response model for label operations like update/delete."""
|
||||
|
||||
|
||||
@@ -25,6 +25,8 @@ from prometheus_client import (
|
||||
start_http_server,
|
||||
)
|
||||
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
@@ -426,8 +428,6 @@ def instrument_tool(func):
|
||||
Wrapped function with metrics and tracing instrumentation
|
||||
"""
|
||||
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
|
||||
@functools.wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
tool_name = func.__name__
|
||||
|
||||
@@ -9,8 +9,12 @@ from dataclasses import dataclass
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,11 +38,6 @@ async def _get_chunk_from_qdrant(
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -104,11 +103,6 @@ async def _get_chunk_by_index_from_qdrant(
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -165,11 +159,6 @@ async def _get_file_path_from_qdrant(
|
||||
File path string, or None if not found in Qdrant
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -225,11 +214,6 @@ async def _get_deck_metadata_from_qdrant(
|
||||
Dictionary with board_id and stack_id, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
@@ -355,8 +339,6 @@ async def get_chunk_with_context(
|
||||
|
||||
# Fetch adjacent chunks for context expansion
|
||||
# Get chunk overlap from config to remove duplicate text
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
chunk_overlap = settings.document_chunk_overlap
|
||||
|
||||
@@ -587,8 +569,6 @@ async def _fetch_document_text(
|
||||
return None
|
||||
elif doc_type == "news_item":
|
||||
# Fetch news item by ID
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_id))
|
||||
# Reconstruct full content as indexed: title + source + URL + body
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
|
||||
@@ -12,11 +12,14 @@ import logging
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -682,8 +685,6 @@ class PDFHighlighter:
|
||||
# Clean up temp directory and PDF file
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
@@ -720,11 +721,6 @@ class PDFHighlighter:
|
||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||
Chunks that fail to highlight are omitted from the result.
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
results: dict[int, tuple[bytes, int, int]] = {}
|
||||
|
||||
if not chunks:
|
||||
@@ -798,9 +794,6 @@ class PDFHighlighter:
|
||||
|
||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||
# This avoids expensive page.get_pixmap() calls per chunk
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# PIL color for bounding box (RGB tuple)
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
|
||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.calendar import (
|
||||
Calendar,
|
||||
CalendarEventSummary,
|
||||
ListCalendarsResponse,
|
||||
ListEventsResponse,
|
||||
ListTodosResponse,
|
||||
Todo,
|
||||
UpcomingEventsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
||||
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
|
||||
raw_categories = event.get("categories", [])
|
||||
if isinstance(raw_categories, str):
|
||||
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
|
||||
else:
|
||||
categories = raw_categories
|
||||
|
||||
start = event.get("start_datetime", "")
|
||||
if not start:
|
||||
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
|
||||
|
||||
return CalendarEventSummary(
|
||||
uid=event.get("uid", ""),
|
||||
summary=event.get("title", ""),
|
||||
start=start,
|
||||
end=event.get("end_datetime"),
|
||||
all_day=event.get("all_day", False),
|
||||
location=event.get("location") or None,
|
||||
description=event.get("description") or None,
|
||||
categories=categories,
|
||||
status=event.get("status"),
|
||||
calendar_name=event.get("calendar_name"),
|
||||
calendar_display_name=event.get("calendar_display_name")
|
||||
or event.get("calendar_name"),
|
||||
)
|
||||
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool(
|
||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
end_datetime=end_datetime,
|
||||
filters=filters if filters else None,
|
||||
)
|
||||
return events[:limit]
|
||||
events = events[:limit]
|
||||
else:
|
||||
# Search in specific calendar
|
||||
events = await client.calendar.get_calendar_events(
|
||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
# Enrich events with calendar context for per-event mapping.
|
||||
# Note: calendar_display_name is not available here without an
|
||||
# extra list_calendars() call; the response-level calendar_name
|
||||
# already identifies the calendar for single-calendar queries.
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
|
||||
# Apply filters if provided
|
||||
if filters:
|
||||
events = client.calendar._apply_event_filters(events, filters)
|
||||
|
||||
return events
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return ListEventsResponse(
|
||||
events=summaries,
|
||||
calendar_name=None if search_all_calendars else calendar_name,
|
||||
start_date=start_date or None,
|
||||
end_date=end_date or None,
|
||||
total_found=len(summaries),
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Calendar Event",
|
||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
if calendar_name:
|
||||
# Get events from specific calendar
|
||||
return await client.calendar.get_calendar_events(
|
||||
events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# calendar_display_name not available without extra API call
|
||||
for event in events:
|
||||
event["calendar_name"] = calendar_name
|
||||
else:
|
||||
# Get events from all calendars
|
||||
all_calendars = await client.calendar.list_calendars()
|
||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
for calendar in all_calendars:
|
||||
try:
|
||||
events = await client.calendar.get_calendar_events(
|
||||
cal_events = await client.calendar.get_calendar_events(
|
||||
calendar_name=calendar["name"],
|
||||
start_datetime=now,
|
||||
end_datetime=end_datetime,
|
||||
limit=limit,
|
||||
)
|
||||
# Add calendar info to each event
|
||||
for event in events:
|
||||
for event in cal_events:
|
||||
event["calendar_name"] = calendar["name"]
|
||||
event["calendar_display_name"] = calendar["display_name"]
|
||||
all_events.extend(events)
|
||||
all_events.extend(cal_events)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
# Sort by start time and limit
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
events = all_events[:limit]
|
||||
|
||||
summaries = [_event_dict_to_summary(e) for e in events]
|
||||
return UpcomingEventsResponse(
|
||||
events=summaries,
|
||||
days_ahead=days_ahead,
|
||||
calendar_name=calendar_name or None,
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Find Availability",
|
||||
|
||||
@@ -1,15 +1,57 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.contacts import (
|
||||
AddressBook,
|
||||
Contact,
|
||||
ContactField,
|
||||
ListAddressBooksResponse,
|
||||
ListContactsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||
|
||||
Only maps fields the client's list_contacts() currently returns:
|
||||
fullname, nickname, birthday, and email. Additional Contact model fields
|
||||
(phones, addresses, organization, etc.) require expanding the client's
|
||||
vCard parsing in ContactsClient.list_contacts().
|
||||
"""
|
||||
contact_info = raw.get("contact", {})
|
||||
|
||||
# Convert email field (str, list, or None) to list[ContactField]
|
||||
raw_email = contact_info.get("email")
|
||||
emails: list[ContactField] = []
|
||||
if isinstance(raw_email, list):
|
||||
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||
elif isinstance(raw_email, str) and raw_email:
|
||||
emails = [ContactField(type="email", value=raw_email)]
|
||||
|
||||
# Nickname goes into custom_fields (no dedicated model field)
|
||||
custom_fields: dict[str, Any] = {}
|
||||
nickname = contact_info.get("nickname")
|
||||
if nickname:
|
||||
custom_fields["nickname"] = nickname
|
||||
|
||||
return Contact(
|
||||
uid=raw["vcard_id"],
|
||||
fn=contact_info.get("fullname", ""),
|
||||
etag=raw.get("getetag"),
|
||||
birthday=contact_info.get("birthday"),
|
||||
emails=emails,
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool(
|
||||
@@ -18,10 +60,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
|
||||
"""List all addressbooks for the user."""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
addressbooks_data = await client.contacts.list_addressbooks()
|
||||
addressbooks = [
|
||||
AddressBook(
|
||||
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
|
||||
# all tools use it as a path segment: f"{carddav_path}/{name}/"
|
||||
uri=ab["name"],
|
||||
displayname=ab.get("display_name", ab["name"]),
|
||||
ctag=ab.get("getctag"),
|
||||
)
|
||||
for ab in addressbooks_data
|
||||
]
|
||||
return ListAddressBooksResponse(
|
||||
addressbooks=addressbooks, total_count=len(addressbooks)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="List Contacts",
|
||||
@@ -29,10 +84,16 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
async def nc_contacts_list_contacts(
|
||||
ctx: Context, *, addressbook: str
|
||||
) -> ListContactsResponse:
|
||||
"""List all contacts in the specified addressbook."""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
||||
contacts = [_raw_contact_to_model(c) for c in contacts_data]
|
||||
return ListContactsResponse(
|
||||
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
||||
)
|
||||
|
||||
@mcp.tool(
|
||||
title="Create Address Book",
|
||||
|
||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
||||
DeckLabel,
|
||||
DeckStack,
|
||||
LabelOperationResponse,
|
||||
ListBoardsResponse,
|
||||
ListCardsResponse,
|
||||
ListLabelsResponse,
|
||||
ListStacksResponse,
|
||||
StackOperationResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return [label.model_dump() for label in board.labels]
|
||||
return [label.model_dump() for label in (board.labels or [])]
|
||||
|
||||
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||
async def deck_label_resource(board_id: int, label_id: int):
|
||||
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||
async def deck_get_boards(ctx: Context) -> ListBoardsResponse:
|
||||
"""Get all Nextcloud Deck boards"""
|
||||
client = await get_client(ctx)
|
||||
boards = await client.deck.get_boards()
|
||||
return boards
|
||||
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Board",
|
||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||
async def deck_get_stacks(ctx: Context, board_id: int) -> ListStacksResponse:
|
||||
"""Get all stacks in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
stacks = await client.deck.get_stacks(board_id)
|
||||
return stacks
|
||||
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Stack",
|
||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
@instrument_tool
|
||||
async def deck_get_cards(
|
||||
ctx: Context, board_id: int, stack_id: int
|
||||
) -> list[DeckCard]:
|
||||
) -> ListCardsResponse:
|
||||
"""Get all cards in a Nextcloud Deck stack"""
|
||||
client = await get_client(ctx)
|
||||
stack = await client.deck.get_stack(board_id, stack_id)
|
||||
if stack.cards:
|
||||
return stack.cards
|
||||
return []
|
||||
cards = stack.cards or []
|
||||
return ListCardsResponse(cards=cards, total=len(cards))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Card",
|
||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("deck:read")
|
||||
@instrument_tool
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||
async def deck_get_labels(ctx: Context, board_id: int) -> ListLabelsResponse:
|
||||
"""Get all labels in a Nextcloud Deck board"""
|
||||
client = await get_client(ctx)
|
||||
board = await client.deck.get_board(board_id)
|
||||
return board.labels
|
||||
labels = board.labels or []
|
||||
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Deck Label",
|
||||
|
||||
@@ -8,10 +8,10 @@ Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
@@ -20,9 +20,13 @@ from mcp.types import ToolAnnotations
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
from ..http import nextcloud_httpx_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +73,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
||||
"OIDC_DISCOVERY_URI",
|
||||
"http://localhost:8080/.well-known/openid-configuration",
|
||||
)
|
||||
async with httpx.AsyncClient() as http_client:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
@@ -156,11 +160,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
||||
Returns:
|
||||
ProvisioningStatus with current provisioning state
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Check for app password first (interim solution)
|
||||
@@ -304,8 +303,6 @@ async def provision_nextcloud_access(
|
||||
|
||||
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
||||
# and ENABLE_OFFLINE_ACCESS environment variables)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.enable_offline_access:
|
||||
return ProvisioningResult(
|
||||
@@ -489,8 +486,6 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||
|
||||
# Not logged in - generate OAuth URL for Flow 2
|
||||
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.enable_offline_access:
|
||||
return (
|
||||
|
||||
@@ -7,15 +7,19 @@ from httpx import RequestError
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.shared.exceptions import McpError
|
||||
from mcp.types import (
|
||||
ClientCapabilities,
|
||||
ErrorData,
|
||||
ModelHint,
|
||||
ModelPreferences,
|
||||
SamplingCapability,
|
||||
SamplingMessage,
|
||||
TextContent,
|
||||
ToolAnnotations,
|
||||
)
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.semantic import (
|
||||
SamplingSearchResponse,
|
||||
@@ -28,6 +32,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
)
|
||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -82,8 +88,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
Returns:
|
||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||
"""
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
client = await get_client(ctx)
|
||||
username = client.username
|
||||
@@ -373,8 +377,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
)
|
||||
|
||||
# 3. Check if client supports sampling
|
||||
from mcp.types import ClientCapabilities, SamplingCapability
|
||||
|
||||
client_has_sampling = ctx.session.check_client_capability(
|
||||
ClientCapabilities(sampling=SamplingCapability())
|
||||
)
|
||||
@@ -658,8 +660,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
"""
|
||||
|
||||
# Check if vector sync is enabled (supports both old and new env var names)
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
if not settings.vector_sync_enabled:
|
||||
return VectorSyncStatusResponse(
|
||||
@@ -694,15 +694,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
||||
# Get Qdrant client and query indexed count
|
||||
indexed_count = 0
|
||||
try:
|
||||
from qdrant_client.models import Filter
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import (
|
||||
get_placeholder_filter,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
# Count documents in collection, excluding placeholders
|
||||
|
||||
@@ -5,6 +5,7 @@ from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
|
||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
|
||||
)
|
||||
@require_scopes("tables:read")
|
||||
@instrument_tool
|
||||
async def nc_tables_list_tables(ctx: Context):
|
||||
async def nc_tables_list_tables(ctx: Context) -> ListTablesResponse:
|
||||
"""List all tables available to the user"""
|
||||
client = await get_client(ctx)
|
||||
return await client.tables.list_tables()
|
||||
tables_data = await client.tables.list_tables()
|
||||
tables = [Table(**t) for t in tables_data]
|
||||
return ListTablesResponse(tables=tables, total_count=len(tables))
|
||||
|
||||
@mcp.tool(
|
||||
title="Get Table Schema",
|
||||
|
||||
@@ -36,7 +36,7 @@ def main():
|
||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||
|
||||
# Import app after setting environment variables
|
||||
from nextcloud_mcp_server.app import get_app
|
||||
from nextcloud_mcp_server.app import get_app # noqa: PLC0415
|
||||
|
||||
# Create the app with streamable-http transport (required for Smithery)
|
||||
app = get_app(transport="streamable-http")
|
||||
|
||||
@@ -31,14 +31,15 @@ from anyio.streams.memory import (
|
||||
MemoryObjectReceiveStream,
|
||||
MemoryObjectSendStream,
|
||||
)
|
||||
from httpx import BasicAuth
|
||||
from httpx import BasicAuth, HTTPStatusError
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.processor import process_document
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -89,8 +90,6 @@ async def get_user_client_basic_auth(
|
||||
Raises:
|
||||
NotProvisionedError: If user has not provisioned an app password
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
# Get or create storage instance
|
||||
if storage is None:
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
@@ -210,9 +209,41 @@ async def user_scanner_task(
|
||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
||||
settings = get_settings()
|
||||
max_consecutive_errors = 5
|
||||
|
||||
task_status.started()
|
||||
|
||||
# Pre-validate credentials before entering scan loop
|
||||
try:
|
||||
nc_client = await get_user_client(
|
||||
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||
)
|
||||
try:
|
||||
await nc_client.capabilities() # Lightweight OCS call to validate creds
|
||||
logger.info(f"[{mode_label}] Credentials validated for {user_id}")
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code in (401, 403):
|
||||
logger.warning(
|
||||
f"[{mode_label}] Credential validation failed for {user_id} "
|
||||
f"(HTTP {e.response.status_code}), not starting scan loop"
|
||||
)
|
||||
return
|
||||
raise
|
||||
finally:
|
||||
await nc_client.close()
|
||||
except NotProvisionedError:
|
||||
logger.warning(
|
||||
f"[{mode_label}] User {user_id} not provisioned, not starting scan loop"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[{mode_label}] Pre-validation failed for {user_id}: {e}. "
|
||||
f"Proceeding to scan loop (has its own error handling)."
|
||||
)
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
while not shutdown_event.is_set():
|
||||
nc_client = None
|
||||
try:
|
||||
@@ -228,21 +259,64 @@ async def user_scanner_task(
|
||||
nc_client=nc_client,
|
||||
)
|
||||
|
||||
consecutive_errors = 0 # Reset on success
|
||||
|
||||
except NotProvisionedError:
|
||||
logger.warning(
|
||||
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
||||
)
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
status_code = e.response.status_code
|
||||
if status_code in (401, 403):
|
||||
logger.warning(
|
||||
f"[{mode_label}] Scanner auth failed for {user_id} "
|
||||
f"(HTTP {status_code}), stopping scanner. "
|
||||
f"User may need to re-provision credentials."
|
||||
)
|
||||
break
|
||||
elif status_code == 429:
|
||||
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
|
||||
logger.warning(
|
||||
f"[{mode_label}] Scanner rate-limited for {user_id}, "
|
||||
f"backing off {retry_after}s"
|
||||
)
|
||||
try:
|
||||
with anyio.move_on_after(retry_after):
|
||||
await shutdown_event.wait()
|
||||
# anyio.get_cancelled_exc_class() catches task cancellation
|
||||
# (e.g. from task group teardown) so we exit cleanly.
|
||||
except anyio.get_cancelled_exc_class():
|
||||
break
|
||||
continue
|
||||
else:
|
||||
consecutive_errors += 1
|
||||
logger.error(
|
||||
f"[{mode_label}] Scanner HTTP error for {user_id}: {e} "
|
||||
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
logger.error(
|
||||
f"[{mode_label}] Scanner error for {user_id}: {e}", exc_info=True
|
||||
f"[{mode_label}] Scanner error for {user_id}: {e} "
|
||||
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
finally:
|
||||
if nc_client:
|
||||
await nc_client.close()
|
||||
|
||||
if consecutive_errors >= max_consecutive_errors:
|
||||
logger.error(
|
||||
f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} "
|
||||
f"consecutive errors, stopping scanner"
|
||||
)
|
||||
break
|
||||
|
||||
# Sleep until next interval or wake event
|
||||
try:
|
||||
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||
@@ -276,8 +350,6 @@ async def multi_user_processor_task(
|
||||
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||
task_status: Status object for signaling task readiness
|
||||
"""
|
||||
from nextcloud_mcp_server.vector.processor import process_document
|
||||
|
||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
||||
task_status.started()
|
||||
|
||||
@@ -21,6 +21,7 @@ import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from qdrant_client import models
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
@@ -82,8 +83,6 @@ async def write_placeholder_point(
|
||||
|
||||
# Create empty sparse vector for placeholders
|
||||
# Use models.SparseVector with empty indices/values
|
||||
from qdrant_client import models
|
||||
|
||||
empty_sparse = models.SparseVector(indices=[], values=[])
|
||||
|
||||
# Generate deterministic point ID
|
||||
|
||||
@@ -17,6 +17,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import (
|
||||
record_qdrant_operation,
|
||||
@@ -24,7 +25,9 @@ from nextcloud_mcp_server.observability.metrics import (
|
||||
update_vector_sync_queue_size,
|
||||
)
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||
@@ -275,8 +278,6 @@ async def _index_document(
|
||||
content_bytes = None # Notes don't have binary content
|
||||
content_type = None
|
||||
elif doc_task.doc_type == "news_item":
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||
# Convert HTML body to Markdown for better embedding
|
||||
body_markdown = html_to_markdown(item.get("body", ""))
|
||||
@@ -437,8 +438,6 @@ async def _index_document(
|
||||
},
|
||||
):
|
||||
# Use document processor registry to extract text
|
||||
from nextcloud_mcp_server.document_processors import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
|
||||
try:
|
||||
@@ -586,8 +585,6 @@ async def _index_document(
|
||||
"vector_sync.pdf_size": len(content_bytes),
|
||||
},
|
||||
):
|
||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||
|
||||
# Build chunk data for batch processing
|
||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||
|
||||
@@ -6,6 +6,7 @@ from qdrant_client import AsyncQdrantClient, models
|
||||
from qdrant_client.models import Distance, VectorParams
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -62,9 +63,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
||||
# Get collection name (auto-generated from deployment ID + model)
|
||||
collection_name = settings.get_collection_name()
|
||||
|
||||
# Import here to avoid circular dependency
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
|
||||
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
||||
|
||||
@@ -8,6 +8,7 @@ import os
|
||||
import random
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import anyio
|
||||
from anyio.abc import TaskStatus
|
||||
@@ -15,6 +16,7 @@ from anyio.streams.memory import MemoryObjectSendStream
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.client.news import NewsItemType
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
@@ -418,8 +420,6 @@ async def scan_user_documents(
|
||||
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
||||
if isinstance(file_info.get("last_modified"), str):
|
||||
# Parse RFC 2822 date format if needed
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(file_info["last_modified"])
|
||||
modified_at = int(dt.timestamp())
|
||||
@@ -615,8 +615,6 @@ async def scan_news_items(
|
||||
Returns:
|
||||
Number of items queued for processing
|
||||
"""
|
||||
from nextcloud_mcp_server.client.news import NewsItemType
|
||||
|
||||
settings = get_settings()
|
||||
queued = 0
|
||||
|
||||
|
||||
+10
-8
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.63.0"
|
||||
version = "0.64.3"
|
||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||
authors = [
|
||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
||||
requires-python = ">=3.11"
|
||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||
dependencies = [
|
||||
"mcp[cli] (>=1.23,<1.24)",
|
||||
"mcp[cli] (>=1.26,<1.27)",
|
||||
"httpx (>=0.28.1,<0.29.0)",
|
||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||
"icalendar (>=6.0.0,<7.0.0)",
|
||||
@@ -98,23 +98,25 @@ version_files = [
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"nextcloud-mcp-server-*", # Helm chart tags
|
||||
"astrolabe-v*", # Astrolabe tags
|
||||
]
|
||||
|
||||
# Filter commits by scope (all scopes except helm and astrolabe)
|
||||
# Filter commits by scope (all scopes except helm)
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||
|
||||
[tool.ruff.lint]
|
||||
extend-select = ["I"]
|
||||
extend-select = ["I", "PLC0415"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["PLC0415"]
|
||||
|
||||
[tool.uv.sources]
|
||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[tool.uv.build-backend]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Bump Astrolabe app version
|
||||
set -euo pipefail
|
||||
|
||||
# Parse optional --increment flag
|
||||
INCREMENT=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--increment)
|
||||
INCREMENT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "❌ Error: Unknown option: $1" >&2
|
||||
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate dependencies
|
||||
command -v uv >/dev/null 2>&1 || {
|
||||
echo "❌ Error: uv not found" >&2
|
||||
echo " Install from https://docs.astral.sh/uv/" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Validate Astrolabe directory exists
|
||||
if [ ! -d "third_party/astrolabe" ]; then
|
||||
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd third_party/astrolabe
|
||||
|
||||
# Validate required files exist
|
||||
if [ ! -f "appinfo/info.xml" ]; then
|
||||
echo "❌ Error: appinfo/info.xml not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "❌ Error: package.json not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Bumping Astrolabe version..."
|
||||
if [ -n "$INCREMENT" ]; then
|
||||
echo " Forcing $INCREMENT bump"
|
||||
fi
|
||||
|
||||
# Build commitizen command
|
||||
CZ_CMD="uv run cz --config .cz.toml bump --yes"
|
||||
if [ -n "$INCREMENT" ]; then
|
||||
CZ_CMD="$CZ_CMD --increment $INCREMENT"
|
||||
fi
|
||||
|
||||
# Run commitizen bump and capture output
|
||||
if ! output=$($CZ_CMD 2>&1); then
|
||||
cd ../..
|
||||
|
||||
# Check if this is the expected "no commits to bump" case
|
||||
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
||||
echo "ℹ️ No commits eligible for version bump" >&2
|
||||
echo "$output" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Otherwise, this is an actual error
|
||||
echo "❌ Error: Version bump failed" >&2
|
||||
echo "$output" >&2
|
||||
echo "" >&2
|
||||
echo "Common causes:" >&2
|
||||
echo " - No commits with scope 'astrolabe' since last version" >&2
|
||||
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
|
||||
echo " - Git working directory not clean" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$output"
|
||||
echo ""
|
||||
echo "✓ Astrolabe version bumped successfully"
|
||||
echo " Updated: appinfo/info.xml, package.json"
|
||||
echo " Tag format: astrolabe-v\${version}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " cd ../.."
|
||||
echo " git push --follow-tags"
|
||||
|
||||
cd ../..
|
||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
||||
raise
|
||||
|
||||
|
||||
async def test_update_event_extended_fields(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||
calendar_name = temporary_calendar
|
||||
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
event_data = {
|
||||
"title": "Extended Fields Update Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Base event for extended-field update test",
|
||||
}
|
||||
|
||||
event_uid = None
|
||||
try:
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||
|
||||
# --- Phase 1: Set all four extended fields ---
|
||||
updated_data = {
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||
|
||||
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories
|
||||
assert "work" in retrieved.get("categories", "")
|
||||
assert "meeting" in retrieved.get("categories", "")
|
||||
|
||||
# Verify recurrence rule
|
||||
assert retrieved.get("recurring") is True
|
||||
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||
|
||||
# Verify attendees
|
||||
attendees = retrieved.get("attendees", "")
|
||||
assert "alice@example.com" in attendees
|
||||
assert "bob@example.com" in attendees
|
||||
|
||||
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||
|
||||
# --- Phase 2: Clear all four extended fields ---
|
||||
cleared_data = {
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
}
|
||||
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Verify categories cleared
|
||||
assert not cleared.get("categories")
|
||||
|
||||
# Verify recurrence cleared
|
||||
assert cleared.get("recurring") is not True
|
||||
assert not cleared.get("recurrence_rule")
|
||||
|
||||
# Verify attendees cleared
|
||||
assert not cleared.get("attendees")
|
||||
|
||||
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Extended fields update test failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def test_create_event_with_attendees(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
||||
raise
|
||||
|
||||
|
||||
async def test_list_events_date_range_filtering(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that date range filtering actually excludes events outside the range.
|
||||
|
||||
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||
but returned events from the entire calendar history, ignoring date filters.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
past_uid = None
|
||||
future_uid = None
|
||||
|
||||
try:
|
||||
# Create Event A: 30 days in the past
|
||||
past_date = datetime.now() - timedelta(days=30)
|
||||
past_event_data = {
|
||||
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||
"description": "Event in the past for date range test",
|
||||
}
|
||||
result_past = await nc_client.calendar.create_event(
|
||||
calendar_name, past_event_data
|
||||
)
|
||||
past_uid = result_past["uid"]
|
||||
logger.info(f"Created past event: {past_uid}")
|
||||
|
||||
# Create Event B: 1 day in the future
|
||||
future_date = datetime.now() + timedelta(days=1)
|
||||
future_event_data = {
|
||||
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Event in the future for date range test",
|
||||
}
|
||||
result_future = await nc_client.calendar.create_event(
|
||||
calendar_name, future_event_data
|
||||
)
|
||||
future_uid = result_future["uid"]
|
||||
logger.info(f"Created future event: {future_uid}")
|
||||
|
||||
# Query with date range: today → 7 days ahead
|
||||
now = datetime.now()
|
||||
week_ahead = now + timedelta(days=7)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=now,
|
||||
end_datetime=week_ahead,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
event_uids = [e["uid"] for e in events]
|
||||
|
||||
# Future event (tomorrow) SHOULD be in results
|
||||
assert future_uid in event_uids, (
|
||||
f"Future event {future_uid} should be in date-filtered results"
|
||||
)
|
||||
|
||||
# Past event (30 days ago) should NOT be in results
|
||||
assert past_uid not in event_uids, (
|
||||
f"Past event {past_uid} should be excluded by date range filter "
|
||||
f"(GH-538: date range was being ignored)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Date range filtering works: {len(events)} events returned, "
|
||||
f"past event correctly excluded"
|
||||
)
|
||||
|
||||
finally:
|
||||
# Cleanup both events
|
||||
for uid in [past_uid, future_uid]:
|
||||
if uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||
|
||||
|
||||
async def test_recurring_event_date_range_expansion(
|
||||
nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test that recurring events are expanded into individual occurrences.
|
||||
|
||||
When querying with a date range, a recurring event should return one
|
||||
event dict per occurrence within the range, each with the correct
|
||||
start_datetime for that occurrence (not the original master event date).
|
||||
|
||||
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||
recurring events, but returned the master event with its original DTSTART
|
||||
instead of expanding occurrences.
|
||||
"""
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# Create a daily recurring event starting 7 days ago
|
||||
start = datetime.now() - timedelta(days=7)
|
||||
event_data = {
|
||||
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||
"description": "Daily recurring event for expansion test",
|
||||
"recurring": True,
|
||||
"recurrence_rule": "FREQ=DAILY",
|
||||
}
|
||||
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||
event_uid = result["uid"]
|
||||
logger.info(f"Created daily recurring event: {event_uid}")
|
||||
|
||||
# Query with date range: today → 3 days ahead
|
||||
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
query_end = query_start + timedelta(days=3)
|
||||
|
||||
events = await nc_client.calendar.get_calendar_events(
|
||||
calendar_name=calendar_name,
|
||||
start_datetime=query_start,
|
||||
end_datetime=query_end,
|
||||
limit=50,
|
||||
)
|
||||
|
||||
# Filter to only our recurring event (calendar may have others)
|
||||
our_events = [e for e in events if e["uid"] == event_uid]
|
||||
|
||||
# Should have multiple occurrences (one per day in the range)
|
||||
assert len(our_events) >= 2, (
|
||||
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||
f"Expansion may not be working."
|
||||
)
|
||||
|
||||
# Each occurrence should have a different start_datetime
|
||||
start_dates = [e["start_datetime"] for e in our_events]
|
||||
assert len(set(start_dates)) == len(our_events), (
|
||||
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||
)
|
||||
|
||||
# No start_datetime should fall outside the queried range
|
||||
for e in our_events:
|
||||
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||
# Remove timezone info for comparison if present
|
||||
if event_start.tzinfo is not None:
|
||||
event_start = event_start.replace(tzinfo=None)
|
||||
assert event_start >= query_start - timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||
)
|
||||
assert event_start < query_end + timedelta(hours=1), (
|
||||
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||
)
|
||||
|
||||
# Expanded occurrences should NOT have recurrence rules
|
||||
# (server strips RRULE when expanding)
|
||||
for e in our_events:
|
||||
assert not e.get("recurring"), (
|
||||
"Expanded occurrence should not have recurring=True, "
|
||||
"RRULE should be stripped by server-side expansion"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||
f"returned with unique start dates"
|
||||
)
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||
|
||||
|
||||
async def test_calendar_operations_error_handling(
|
||||
nc_client: NextcloudClient,
|
||||
):
|
||||
|
||||
@@ -2400,6 +2400,32 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
||||
except Exception as e:
|
||||
logger.warning(f"Error deleting test user {username}: {e}")
|
||||
|
||||
# Clean up all app passwords from MCP server to prevent stale scanners
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"mcp-multi-user-basic",
|
||||
"sqlite3",
|
||||
"/app/data/tokens.db",
|
||||
"DELETE FROM app_passwords;",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
f"Failed to clean up app passwords (rc={result.returncode}): "
|
||||
f"{result.stderr}"
|
||||
)
|
||||
else:
|
||||
logger.info("Cleaned up all test app passwords")
|
||||
|
||||
|
||||
async def _get_oauth_token_for_user(
|
||||
browser,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration test for multi-user Astrolabe background sync enablement.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test verifies that multiple users can independently:
|
||||
1. Log in to Nextcloud
|
||||
2. Generate an app password in Security settings
|
||||
@@ -829,6 +834,70 @@ async def verify_app_password_created(username: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
||||
"""Clear stale app passwords, bruteforce entries, and optionally Astrolabe preferences."""
|
||||
commands: list[tuple[list[str], str]] = [
|
||||
(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"mcp-multi-user-basic",
|
||||
"sqlite3",
|
||||
"/app/data/tokens.db",
|
||||
"DELETE FROM app_passwords;",
|
||||
],
|
||||
"app passwords",
|
||||
),
|
||||
(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
"DELETE FROM oc_bruteforce_attempts;",
|
||||
],
|
||||
"bruteforce entries",
|
||||
),
|
||||
]
|
||||
if clear_preferences:
|
||||
commands.append(
|
||||
(
|
||||
[
|
||||
"docker",
|
||||
"compose",
|
||||
"exec",
|
||||
"-T",
|
||||
"db",
|
||||
"mariadb",
|
||||
"-u",
|
||||
"root",
|
||||
"-ppassword",
|
||||
"nextcloud",
|
||||
"-e",
|
||||
"DELETE FROM oc_preferences WHERE appid = 'astrolabe';",
|
||||
],
|
||||
"Astrolabe preferences",
|
||||
),
|
||||
)
|
||||
for cmd, label in commands:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||
if result.returncode != 0:
|
||||
logger.warning(
|
||||
f"Failed to clear {label} (rc={result.returncode}): {result.stderr}"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Cleared {label}")
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.oauth
|
||||
async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
@@ -856,6 +925,10 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
||||
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
|
||||
in multi-user BasicAuth deployments.
|
||||
"""
|
||||
# Clear stale state from previous test runs
|
||||
logger.info("Clearing stale app passwords and bruteforce entries...")
|
||||
clear_stale_test_state()
|
||||
|
||||
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
@@ -1193,6 +1266,12 @@ async def test_revoke_background_sync_access(
|
||||
This tests the fix for the issue where POST requests to the revoke endpoint
|
||||
were returning errors due to HTTP method mismatch (was DELETE, now POST).
|
||||
"""
|
||||
# Clear stale state from previous test runs
|
||||
logger.info(
|
||||
"Clearing stale app passwords, bruteforce entries, and Astrolabe preferences..."
|
||||
)
|
||||
clear_stale_test_state(clear_preferences=True)
|
||||
|
||||
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||
await configure_astrolabe_for_mcp_server(
|
||||
@@ -1213,9 +1292,14 @@ async def test_revoke_background_sync_access(
|
||||
# Step 1: Login to Nextcloud
|
||||
await login_to_nextcloud(page, username, password)
|
||||
|
||||
# Step 2: Generate app password and enable background sync
|
||||
app_password = await generate_app_password(page, username)
|
||||
await enable_background_sync_via_app_password(page, username, app_password)
|
||||
# Step 2: Complete full authorization (OAuth Step 1 + App Password Step 2)
|
||||
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||
assert auth_result["step1"], (
|
||||
f"OAuth authorization (Step 1) failed for {username}"
|
||||
)
|
||||
assert auth_result["step2"], (
|
||||
f"App password setup (Step 2) failed for {username}"
|
||||
)
|
||||
|
||||
# Step 3: Verify background sync is enabled
|
||||
assert await verify_app_password_created(username), (
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test verifies that:
|
||||
1. User can provision background sync access via app password
|
||||
2. Content created via MCP tools is indexed by vector sync
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration tests for Astrolabe personal settings page buttons.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
Tests the button functionality on /settings/user/astrolabe:
|
||||
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
|
||||
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Integration tests for Astrolabe token refresh flow.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
|
||||
and the MCP server backend in a multi-user basic auth deployment.
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Test Astrolabe integration with multiple MCP server deployments.
|
||||
|
||||
Cross-system interface test: Tests the MCP server's integration with the
|
||||
Astrolabe Nextcloud app, which is installed from the Nextcloud app store via
|
||||
app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source
|
||||
lives in a separate repository (https://github.com/cbcoutinho/astrolabe).
|
||||
|
||||
This test suite verifies that the Astrolabe app can be dynamically configured
|
||||
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
||||
|
||||
|
||||
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
|
||||
|
||||
# Bob should see the shared board
|
||||
if board_id in board_ids:
|
||||
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
logger.info(f"Diana can see {len(response_data)} boards")
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Diana can see {len(board_list)} boards")
|
||||
|
||||
# Diana should NOT see the board
|
||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
||||
@@ -313,10 +311,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Alice can see boards: {board_ids}")
|
||||
|
||||
# Alice should NOT see Bob's board
|
||||
@@ -332,10 +329,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
||||
|
||||
if not result.isError:
|
||||
response_data = json.loads(result.content[0].text)
|
||||
# The response is directly a list of boards
|
||||
if not isinstance(response_data, list):
|
||||
response_data = [response_data] if response_data else []
|
||||
board_ids = [b["id"] for b in response_data]
|
||||
# Response is a ListBoardsResponse with a "boards" field
|
||||
board_list = response_data.get("boards", [])
|
||||
board_ids = [b["id"] for b in board_list]
|
||||
logger.info(f"Bob can see boards: {board_ids}")
|
||||
|
||||
# Bob should NOT see Alice's board
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Integration tests for Calendar VEVENT update MCP tools - extended fields."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from mcp import ClientSession
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
async def test_mcp_update_event_extended_fields(
|
||||
nc_mcp_client: ClientSession, nc_client: NextcloudClient, temporary_calendar: str
|
||||
):
|
||||
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes via MCP."""
|
||||
|
||||
calendar_name = temporary_calendar
|
||||
event_uid = None
|
||||
|
||||
try:
|
||||
# 1. Create a base event via MCP
|
||||
tomorrow = datetime.now() + timedelta(days=1)
|
||||
create_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_create_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"title": "Extended Fields MCP Test",
|
||||
"start_datetime": tomorrow.strftime("%Y-%m-%dT14:00:00"),
|
||||
"end_datetime": tomorrow.strftime("%Y-%m-%dT15:00:00"),
|
||||
"description": "Base event for MCP extended-field update test",
|
||||
},
|
||||
)
|
||||
assert create_result.isError is False, (
|
||||
f"MCP event creation failed: {create_result.content}"
|
||||
)
|
||||
|
||||
result_data = json.loads(create_result.content[0].text)
|
||||
event_uid = result_data["uid"]
|
||||
logger.info(f"Created base event via MCP: {event_uid}")
|
||||
|
||||
# 2. Update with all four extended fields via MCP
|
||||
update_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "work,meeting",
|
||||
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||
"attendees": "alice@example.com,bob@example.com",
|
||||
"reminder_minutes": 15,
|
||||
},
|
||||
)
|
||||
assert update_result.isError is False, (
|
||||
f"MCP event update failed: {update_result.content}"
|
||||
)
|
||||
|
||||
# 3. Verify via direct client
|
||||
event, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
|
||||
# Categories
|
||||
assert "work" in event.get("categories", ""), (
|
||||
f"Expected 'work' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
assert "meeting" in event.get("categories", ""), (
|
||||
f"Expected 'meeting' in categories, got: {event.get('categories')}"
|
||||
)
|
||||
|
||||
# Recurrence
|
||||
assert event.get("recurring") is True, "Expected event to be recurring"
|
||||
assert "WEEKLY" in event.get("recurrence_rule", ""), (
|
||||
f"Expected WEEKLY in rrule, got: {event.get('recurrence_rule')}"
|
||||
)
|
||||
|
||||
# Attendees
|
||||
attendees = event.get("attendees", "")
|
||||
assert "alice@example.com" in attendees, (
|
||||
f"Expected alice in attendees, got: {attendees}"
|
||||
)
|
||||
assert "bob@example.com" in attendees, (
|
||||
f"Expected bob in attendees, got: {attendees}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields update verified successfully")
|
||||
|
||||
# 4. Clear all four fields via MCP
|
||||
clear_result = await nc_mcp_client.call_tool(
|
||||
"nc_calendar_update_event",
|
||||
{
|
||||
"calendar_name": calendar_name,
|
||||
"event_uid": event_uid,
|
||||
"categories": "",
|
||||
"recurrence_rule": "",
|
||||
"attendees": "",
|
||||
"reminder_minutes": 0,
|
||||
},
|
||||
)
|
||||
assert clear_result.isError is False, (
|
||||
f"MCP event clear failed: {clear_result.content}"
|
||||
)
|
||||
|
||||
# 5. Verify fields cleared
|
||||
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||
assert not cleared.get("categories"), (
|
||||
f"Expected categories cleared, got: {cleared.get('categories')}"
|
||||
)
|
||||
assert cleared.get("recurring") is not True, (
|
||||
f"Expected recurring cleared, got: {cleared.get('recurring')}"
|
||||
)
|
||||
assert not cleared.get("attendees"), (
|
||||
f"Expected attendees cleared, got: {cleared.get('attendees')}"
|
||||
)
|
||||
|
||||
logger.info("MCP extended fields clear verified successfully")
|
||||
|
||||
finally:
|
||||
if event_uid:
|
||||
try:
|
||||
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||
except Exception:
|
||||
pass
|
||||
+15
-21
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP list events failed: {list_result.content}"
|
||||
)
|
||||
|
||||
events_data = json.loads(list_result.content[0].text)
|
||||
events_response = json.loads(list_result.content[0].text)
|
||||
|
||||
# Debug output to understand what nc_calendar_list_events returns
|
||||
logger.info(f"list_events result type: {type(events_data)}")
|
||||
logger.info(f"list_events result content: {events_data}")
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
events_data = [events_data]
|
||||
logger.info(f"list_events result type: {type(events_response)}")
|
||||
logger.info(f"list_events result content: {events_response}")
|
||||
|
||||
# Response is now a ListEventsResponse with an "events" field
|
||||
assert isinstance(events_response, dict), "Expected response dict"
|
||||
events_data = events_response.get("events", [])
|
||||
assert isinstance(events_data, list), "Expected events list"
|
||||
|
||||
# Our created event should be in the list
|
||||
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
|
||||
assert found_event is not None, (
|
||||
f"Created event {event_uid} not found in events list"
|
||||
)
|
||||
assert found_event["title"] == test_event_title
|
||||
assert found_event["summary"] == test_event_title
|
||||
|
||||
# 6. Test list events across all calendars
|
||||
logger.info("Testing nc_calendar_list_events across all calendars")
|
||||
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP list all events failed: {all_list_result.content}"
|
||||
)
|
||||
|
||||
all_events_data = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
||||
if isinstance(all_events_data, dict):
|
||||
# Single event returned as dict instead of list
|
||||
all_events_data = [all_events_data]
|
||||
all_events_response = json.loads(all_list_result.content[0].text)
|
||||
|
||||
# Response is now a ListEventsResponse with an "events" field
|
||||
assert isinstance(all_events_response, dict), "Expected response dict"
|
||||
all_events_data = all_events_response.get("events", [])
|
||||
assert isinstance(all_events_data, list), "Expected events list"
|
||||
|
||||
# Our event should still be found when searching all calendars
|
||||
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
|
||||
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||
)
|
||||
|
||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Handle single event returned as dict instead of list (same fix as other tools)
|
||||
if isinstance(upcoming_events, dict):
|
||||
# Single event returned as dict instead of list
|
||||
upcoming_events = [upcoming_events]
|
||||
upcoming_response = json.loads(upcoming_result.content[0].text)
|
||||
|
||||
# Response is now an UpcomingEventsResponse with an "events" field
|
||||
assert isinstance(upcoming_response, dict), "Expected response dict"
|
||||
upcoming_events = upcoming_response.get("events", [])
|
||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||
|
||||
# 10. Delete event via MCP
|
||||
|
||||
@@ -185,7 +185,11 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -230,7 +234,11 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
||||
"""Test that failed Nextcloud validation returns 401."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
@@ -349,7 +357,11 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -393,7 +405,11 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
||||
# Mock settings (imported locally in the function)
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client for Nextcloud validation
|
||||
@@ -432,7 +448,11 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
||||
"""Test that invalid credentials returns 401 for deletion."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
@@ -502,7 +522,11 @@ async def test_provision_app_password_rate_limiting(mocker):
|
||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401 (failed validation)
|
||||
@@ -561,7 +585,11 @@ async def test_rate_limiting_is_per_user(mocker):
|
||||
"""Test that rate limiting is applied per user, not globally."""
|
||||
mocker.patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
||||
return_value=MagicMock(
|
||||
nextcloud_host="http://localhost:8080",
|
||||
nextcloud_verify_ssl=True,
|
||||
nextcloud_ca_bundle=None,
|
||||
),
|
||||
)
|
||||
|
||||
# Mock httpx client to return 401
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.models.contacts import (
|
||||
Contact,
|
||||
ListContactsResponse,
|
||||
)
|
||||
from nextcloud_mcp_server.models.notes import (
|
||||
CreateNoteResponse,
|
||||
Note,
|
||||
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
|
||||
SamplingSearchResponse,
|
||||
SemanticSearchResult,
|
||||
)
|
||||
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
|
||||
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -267,3 +273,218 @@ def test_sampling_search_response_serialization():
|
||||
assert data["model_used"] == "claude-3-5-sonnet"
|
||||
assert data["stop_reason"] == "maxTokens"
|
||||
assert data["success"] is True
|
||||
|
||||
|
||||
def _map_contact(raw: dict) -> Contact:
|
||||
"""Thin wrapper around the production mapping function for test readability."""
|
||||
return _raw_contact_to_model(raw)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_preserves_email_birthday_nickname():
|
||||
"""Test that list_contacts mapping preserves email, birthday, and nickname.
|
||||
|
||||
Regression test for PR #574: the original mapping only kept uid, fn, etag
|
||||
and silently dropped email, birthday, and nickname.
|
||||
"""
|
||||
raw_contact = {
|
||||
"vcard_id": "abc-123",
|
||||
"getetag": '"etag-val"',
|
||||
"contact": {
|
||||
"fullname": "Jane Doe",
|
||||
"email": "jane@example.com",
|
||||
"birthday": "1990-05-15",
|
||||
"nickname": "JD",
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert contact.uid == "abc-123"
|
||||
assert contact.fn == "Jane Doe"
|
||||
assert contact.etag == '"etag-val"'
|
||||
assert contact.birthday == "1990-05-15"
|
||||
assert len(contact.emails) == 1
|
||||
assert contact.emails[0].value == "jane@example.com"
|
||||
assert contact.emails[0].type == "email"
|
||||
assert contact.custom_fields["nickname"] == "JD"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_multiple_emails():
|
||||
"""Test that multiple emails are mapped correctly."""
|
||||
raw_contact = {
|
||||
"vcard_id": "def-456",
|
||||
"contact": {
|
||||
"fullname": "John Smith",
|
||||
"email": ["john@work.com", "john@home.com"],
|
||||
},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert len(contact.emails) == 2
|
||||
assert contact.emails[0].value == "john@work.com"
|
||||
assert contact.emails[1].value == "john@home.com"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_contact_mapping_missing_optional_fields():
|
||||
"""Test mapping when email, birthday, and nickname are absent."""
|
||||
raw_contact = {
|
||||
"vcard_id": "ghi-789",
|
||||
"contact": {"fullname": "No Details"},
|
||||
}
|
||||
|
||||
contact = _map_contact(raw_contact)
|
||||
|
||||
assert contact.uid == "ghi-789"
|
||||
assert contact.fn == "No Details"
|
||||
assert contact.birthday is None
|
||||
assert contact.emails == []
|
||||
assert contact.custom_fields == {}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_contacts_response_wraps_contacts():
|
||||
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
|
||||
contacts = [
|
||||
_map_contact(
|
||||
{
|
||||
"vcard_id": "a",
|
||||
"getetag": '"e1"',
|
||||
"contact": {
|
||||
"fullname": "Alice",
|
||||
"email": "alice@test.com",
|
||||
"birthday": "2000-01-01",
|
||||
"nickname": "Ali",
|
||||
},
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
response = ListContactsResponse(
|
||||
contacts=contacts, addressbook="personal", total_count=1
|
||||
)
|
||||
|
||||
data = response.model_dump()
|
||||
assert data["total_count"] == 1
|
||||
assert len(data["contacts"]) == 1
|
||||
c = data["contacts"][0]
|
||||
assert c["birthday"] == "2000-01-01"
|
||||
assert c["emails"][0]["value"] == "alice@test.com"
|
||||
assert c["custom_fields"]["nickname"] == "Ali"
|
||||
|
||||
|
||||
# ============= _event_dict_to_summary tests =============
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_basic():
|
||||
"""Test basic mapping with all fields populated."""
|
||||
event = {
|
||||
"uid": "evt-001",
|
||||
"title": "Team Standup",
|
||||
"start_datetime": "2025-07-28T09:00:00",
|
||||
"end_datetime": "2025-07-28T09:30:00",
|
||||
"all_day": False,
|
||||
"location": "Room 42",
|
||||
"description": "Daily sync",
|
||||
"categories": ["work", "meeting"],
|
||||
"status": "CONFIRMED",
|
||||
"calendar_name": "office",
|
||||
"calendar_display_name": "Office Calendar",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.uid == "evt-001"
|
||||
assert summary.summary == "Team Standup"
|
||||
assert summary.start == "2025-07-28T09:00:00"
|
||||
assert summary.end == "2025-07-28T09:30:00"
|
||||
assert summary.all_day is False
|
||||
assert summary.location == "Room 42"
|
||||
assert summary.description == "Daily sync"
|
||||
assert summary.categories == ["work", "meeting"]
|
||||
assert summary.status == "CONFIRMED"
|
||||
assert summary.calendar_name == "office"
|
||||
assert summary.calendar_display_name == "Office Calendar"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_categories_string():
|
||||
"""Test that comma-separated category string is split into a list."""
|
||||
event = {
|
||||
"uid": "evt-002",
|
||||
"title": "Review",
|
||||
"categories": "work, meeting, important",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.categories == ["work", "meeting", "important"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_categories_list_passthrough():
|
||||
"""Test that a list of categories passes through unchanged."""
|
||||
event = {
|
||||
"uid": "evt-003",
|
||||
"title": "Review",
|
||||
"categories": ["personal", "health"],
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.categories == ["personal", "health"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_falsy_location_description():
|
||||
"""Test that empty/falsy location and description are coerced to None."""
|
||||
event = {
|
||||
"uid": "evt-004",
|
||||
"title": "Quick Chat",
|
||||
"location": "",
|
||||
"description": "",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.location is None
|
||||
assert summary.description is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_missing_optional_fields():
|
||||
"""Test mapping with only required fields present."""
|
||||
event = {"uid": "evt-005", "title": "Minimal Event"}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.uid == "evt-005"
|
||||
assert summary.summary == "Minimal Event"
|
||||
assert summary.start == ""
|
||||
assert summary.end is None
|
||||
assert summary.all_day is False
|
||||
assert summary.location is None
|
||||
assert summary.description is None
|
||||
assert summary.categories == []
|
||||
assert summary.status is None
|
||||
assert summary.calendar_name is None
|
||||
assert summary.calendar_display_name is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_event_dict_to_summary_calendar_name_without_display_name():
|
||||
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
|
||||
event = {
|
||||
"uid": "evt-006",
|
||||
"title": "Personal Errand",
|
||||
"calendar_name": "personal",
|
||||
}
|
||||
|
||||
summary = _event_dict_to_summary(event)
|
||||
|
||||
assert summary.calendar_name == "personal"
|
||||
assert summary.calendar_display_name == "personal"
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from unittest.mock import patch
|
||||
|
||||
import certifi
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings
|
||||
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
|
||||
|
||||
|
||||
class TestSSLSettings:
|
||||
"""Test SSL/TLS fields on Settings dataclass."""
|
||||
|
||||
def test_defaults(self):
|
||||
"""verify_ssl defaults to True, ca_bundle defaults to None."""
|
||||
settings = Settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
assert settings.nextcloud_ca_bundle is None
|
||||
|
||||
def test_verify_ssl_false_logs_warning(self, caplog):
|
||||
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||
Settings(nextcloud_verify_ssl=False)
|
||||
assert "NEXTCLOUD_VERIFY_SSL is disabled" in caplog.text
|
||||
|
||||
def test_ca_bundle_nonexistent_path_raises(self):
|
||||
with pytest.raises(ValueError, match="does not exist"):
|
||||
Settings(nextcloud_ca_bundle="/nonexistent/path/ca.pem")
|
||||
|
||||
def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path):
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config")
|
||||
settings = Settings(nextcloud_ca_bundle=str(ca_file))
|
||||
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||
assert "Using custom CA bundle" in caplog.text
|
||||
|
||||
|
||||
class TestGetNextcloudSSLVerify:
|
||||
"""Test the get_nextcloud_ssl_verify() helper function."""
|
||||
|
||||
def test_default_returns_true(self):
|
||||
env = {
|
||||
"NEXTCLOUD_VERIFY_SSL": "true",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
# Clear any cached settings
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is True
|
||||
|
||||
def test_verify_false_returns_false(self):
|
||||
env = {
|
||||
"NEXTCLOUD_VERIFY_SSL": "false",
|
||||
}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_verify_ssl=False),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is False
|
||||
|
||||
def test_ca_bundle_returns_ssl_context(self):
|
||||
ca_bundle = certifi.where()
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
|
||||
def test_ca_bundle_ssl_context_has_loaded_certs(self):
|
||||
"""SSLContext created from CA bundle should have loaded certificates."""
|
||||
ca_bundle = certifi.where()
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert isinstance(result, ssl.SSLContext)
|
||||
stats = result.cert_store_stats()
|
||||
assert stats["x509_ca"] > 0
|
||||
|
||||
def test_verify_false_takes_precedence_over_ca_bundle(self, tmp_path):
|
||||
"""When verify_ssl=False, ca_bundle is ignored (False wins)."""
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
with patch(
|
||||
"nextcloud_mcp_server.config.get_settings",
|
||||
return_value=Settings(
|
||||
nextcloud_verify_ssl=False,
|
||||
nextcloud_ca_bundle=str(ca_file),
|
||||
),
|
||||
):
|
||||
result = get_nextcloud_ssl_verify()
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestGetSettingsSSLEnvVars:
|
||||
"""Test that get_settings() reads SSL env vars correctly."""
|
||||
|
||||
def test_verify_ssl_env_true(self):
|
||||
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
|
||||
def test_verify_ssl_env_false(self):
|
||||
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is False
|
||||
|
||||
def test_verify_ssl_env_missing_defaults_true(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
# Remove NEXTCLOUD_VERIFY_SSL if it exists
|
||||
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_verify_ssl is True
|
||||
|
||||
def test_ca_bundle_env(self, tmp_path):
|
||||
ca_file = tmp_path / "ca.pem"
|
||||
ca_file.write_text(
|
||||
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||
)
|
||||
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
|
||||
with patch.dict(os.environ, env, clear=False):
|
||||
settings = get_settings()
|
||||
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||
|
||||
|
||||
class TestHTTPClientFactory:
|
||||
"""Test that factory functions apply verify correctly."""
|
||||
|
||||
def test_client_applies_verify_true(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client()
|
||||
# httpx stores verify as an SSLConfig; check the _transport
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_client_applies_verify_false(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||
):
|
||||
client = nextcloud_httpx_client()
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_client_caller_override_takes_precedence(self):
|
||||
"""Caller-supplied verify kwarg should not be overridden."""
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client(verify=False)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
|
||||
def test_transport_applies_verify(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||
):
|
||||
transport = nextcloud_httpx_transport()
|
||||
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||
|
||||
def test_client_passes_extra_kwargs(self):
|
||||
with patch(
|
||||
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||
):
|
||||
client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True)
|
||||
assert isinstance(client, httpx.AsyncClient)
|
||||
+1
Submodule third_party/astrolabe added at c079a70af8
Vendored
-25
@@ -1,25 +0,0 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.10.0"
|
||||
tag_format = "astrolabe-v$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
# Update Astrolabe-specific files only
|
||||
version_files = [
|
||||
"appinfo/info.xml:<version>",
|
||||
"package.json:version"
|
||||
]
|
||||
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"v*", # MCP server tags
|
||||
"nextcloud-mcp-server-*", # Helm chart tags
|
||||
]
|
||||
|
||||
# Filter commits by scope
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:\\s.+"
|
||||
message_template = "{{change_type}}(astrolabe): {{message}}"
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@nextcloud',
|
||||
],
|
||||
rules: {
|
||||
'jsdoc/require-jsdoc': 'off',
|
||||
'vue/first-attribute-linebreak': 'off',
|
||||
},
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
/.idea/
|
||||
/*.iml
|
||||
|
||||
/vendor/
|
||||
/vendor-bin/*/vendor/
|
||||
|
||||
/.php-cs-fixer.cache
|
||||
/tests/.phpunit.cache
|
||||
|
||||
dist/
|
||||
build/
|
||||
node_modules/
|
||||
js/
|
||||
css/
|
||||
.phpunit.cache/
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user