Compare commits
188 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f31d16158 | |||
| 7c0b84d398 | |||
| 010eb40d5c | |||
| 10d44edf4c | |||
| f5b4658d5a | |||
| 39d160ce48 | |||
| a11ae9c027 | |||
| 81efa6e263 | |||
| aaddd0d5a9 | |||
| a5eb16c1ac | |||
| 6f7a06e558 | |||
| 0e4c8453bf | |||
| 2dba3179bd | |||
| 5f0e208193 | |||
| 3779ec3e17 | |||
| f2df19c39b | |||
| 5562c943c0 | |||
| 12c02ffe00 | |||
| d2e1391f37 | |||
| ac91aacaf5 | |||
| ad9fcddca1 | |||
| 0e57cf6389 | |||
| b9a185ba1c | |||
| 9aa6b44397 | |||
| 1aa21663b7 | |||
| d145e4d5de | |||
| cf4ed4a641 | |||
| 8d84d95ada | |||
| 992d380585 | |||
| e51fc48206 | |||
| 2657071404 | |||
| 75325f16fc | |||
| 1d4ff3fbe0 | |||
| 778b08cc84 | |||
| 8cab588f21 | |||
| 8233cc9dcf | |||
| 0d259d2dfd | |||
| dfc676a847 | |||
| cf627a9c48 | |||
| 037e88e416 | |||
| dae2f276ae | |||
| d94610d0ec | |||
| af0b9c1f93 | |||
| 2d7360ebd7 | |||
| 56542802bc | |||
| c03dbd1b55 | |||
| 99925d9f22 | |||
| 0dfaf954d7 | |||
| b3fe7099cb | |||
| 7152537fd4 | |||
| 9d31925f27 | |||
| 3a322c34bc | |||
| b1bd025aac | |||
| 8a1c604d78 | |||
| 3616dee54c | |||
| dbb36a7b63 | |||
| f1797b2f8e | |||
| 1d5d4f86d7 | |||
| 44030805f1 | |||
| afd7e69f76 | |||
| 31be72ae24 | |||
| 6bd05a81bf | |||
| a4e3f0b354 | |||
| 0f23964752 | |||
| 66ccacdee1 | |||
| 1a4486a388 | |||
| 91d06acfb4 | |||
| 90874ca7cd | |||
| da8fed3382 | |||
| 8963e65f1b | |||
| 75c3868e74 | |||
| 1707b2e6e1 | |||
| df3cce4370 | |||
| 1c5e21843e | |||
| 520ef113ba | |||
| 3be229a487 | |||
| 6da69b0336 | |||
| 427e501691 | |||
| 9c275d1a3f | |||
| af43630ca7 | |||
| 49c5439686 | |||
| c5eec64716 | |||
| 3948f6a019 | |||
| 08d37a6597 | |||
| 4712235390 | |||
| d0f18b36e8 | |||
| aca0d236b4 | |||
| 7ab0dcd3d8 | |||
| eafef986f2 | |||
| 8126beb16e | |||
| bce6686494 | |||
| dfc75a8619 | |||
| 254cb6cf06 | |||
| 940e7d3e4e | |||
| ac985b265e | |||
| e7f452342e | |||
| e4b5617a55 | |||
| 291a13c064 | |||
| 0e9fee5616 | |||
| 093f1d7302 | |||
| 9da5f95bcb | |||
| 1d4aede0f9 | |||
| ec8eab99f3 | |||
| da104c59ac | |||
| b3e55d444b | |||
| 1786e204ec | |||
| 0a599c5c03 | |||
| 66e32d4705 | |||
| 8603ed114e | |||
| 7e6ef90423 | |||
| c5f2c8369f | |||
| b79ac29a9d | |||
| 334d62825c | |||
| 2233cb423c | |||
| 196a6cdfb2 | |||
| 93f5e70128 | |||
| e5248e70ee | |||
| 018b946b5b | |||
| 863ba0d52a | |||
| d3903c5e2e | |||
| 6ea97c5b88 | |||
| c12c825b11 | |||
| 3d8f7692a8 | |||
| b21c874c14 | |||
| a4661099e5 | |||
| a46d74d999 | |||
| 92f69c8dba | |||
| 6692a85007 | |||
| 1f09079b5a | |||
| 2535c95f4e | |||
| 4fac0ca40d | |||
| 719a432a95 | |||
| 14c4512ef8 | |||
| 6f482c9245 | |||
| a6ad3707c6 | |||
| b34f8d96e3 | |||
| d948f51b10 | |||
| 5eb5b5023c | |||
| 504213ae79 | |||
| 5eeaafbe95 | |||
| 0ddc62c371 | |||
| 36d901d5ae | |||
| 119a422a35 | |||
| 0a3052d0d9 | |||
| 2b691f1792 | |||
| e3da2e006c | |||
| 4539f2f486 | |||
| c85ad95faf | |||
| 60f7234908 | |||
| 1dd5698389 | |||
| 3a0096f8df | |||
| 7bcffd1e96 | |||
| 9674366312 | |||
| a7581a1d1b | |||
| 0ff442d61c | |||
| 96598510ee | |||
| 02cb1f5491 | |||
| 3856698d0a | |||
| 3a05f0cfb3 | |||
| fe5e7f7a60 | |||
| b7257f4e59 | |||
| 7cc852f0da | |||
| 525258be67 | |||
| 49bd3100ad | |||
| 6693bab9f9 | |||
| 8e0d64f7d3 | |||
| c97ffe8e47 | |||
| d0115170c2 | |||
| 9ec00d4de5 | |||
| 9527427782 | |||
| fbfc8b8a05 | |||
| e85000424d | |||
| 58ac60be12 | |||
| 77ef928060 | |||
| 00afac8e46 | |||
| d22cebc69a | |||
| 151d595360 | |||
| 7e02a58546 | |||
| 25dee9bfaf | |||
| f898d61077 | |||
| 0aaa3fc912 | |||
| 77fabccdb7 | |||
| 2648ef2567 | |||
| 405a57649a | |||
| 252df1d398 | |||
| 0ad81a1fd8 | |||
| dce864e947 | |||
| b9f1040dd5 |
@@ -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
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Bump MCP server (default - all commits except helm/astrolabe scopes)
|
# Bump MCP server (default - all commits except helm scope)
|
||||||
echo "Checking MCP server for version bump..."
|
echo "Checking MCP server for version bump..."
|
||||||
|
|
||||||
# Get the most recent MCP tag
|
# Get the most recent MCP tag
|
||||||
@@ -83,9 +83,9 @@ jobs:
|
|||||||
commit_range="${last_mcp_tag}..HEAD"
|
commit_range="${last_mcp_tag}..HEAD"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Count conventional commits that are NOT scoped to helm or astrolabe
|
# Count conventional commits that are NOT scoped to helm
|
||||||
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
|
||||||
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
|
{ grep -v "(helm)" || true; } | wc -l)
|
||||||
|
|
||||||
MCP_BUMPED=false
|
MCP_BUMPED=false
|
||||||
if [ "$mcp_commit_count" -gt 0 ]; then
|
if [ "$mcp_commit_count" -gt 0 ]; then
|
||||||
@@ -115,14 +115,6 @@ jobs:
|
|||||||
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
|
||||||
fi
|
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
|
# Output summary
|
||||||
if [ -z "$BUMPED_COMPONENTS" ]; then
|
if [ -z "$BUMPED_COMPONENTS" ]; then
|
||||||
echo "No components required version bumps"
|
echo "No components required version bumps"
|
||||||
@@ -158,10 +150,6 @@ jobs:
|
|||||||
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||||
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
;;
|
;;
|
||||||
astrolabe)
|
|
||||||
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
|
||||||
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
|
||||||
;;
|
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
allowed_bots: "renovate-bot-cbcoutinho"
|
allowed_bots: "renovate-bot-cbcoutinho"
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@f64219702d7454cf29fe32a74104be6ed43dc637 # v1.0.34
|
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
@@ -34,18 +34,18 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: |
|
compose-file: |
|
||||||
./docker-compose.yml
|
./docker-compose.yml
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: rag-evaluation-results
|
name: rag-evaluation-results
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
submodules: 'true'
|
submodules: 'true'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
- name: Set up php 8.4
|
- name: Set up php 8.4
|
||||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
@@ -48,32 +48,15 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
|
|
||||||
###### Required to build Astrolabe App ######
|
|
||||||
|
|
||||||
- name: Set up Node.js for Astrolabe
|
|
||||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
|
||||||
with:
|
|
||||||
node-version: '20'
|
|
||||||
|
|
||||||
- name: Build Astrolabe app
|
|
||||||
run: |
|
|
||||||
cd third_party/astrolabe
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
npm ci
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
###### Required to build Astrolabe App ######
|
|
||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@05da55b2bb8a5a759d1c4732095044bd9018c050 # v2.4.3
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
#compose-flags: "--profile qdrant"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # v7.2.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -4,3 +4,6 @@
|
|||||||
[submodule "third_party/notes"]
|
[submodule "third_party/notes"]
|
||||||
path = third_party/notes
|
path = third_party/notes
|
||||||
url = https://github.com/cbcoutinho/notes
|
url = https://github.com/cbcoutinho/notes
|
||||||
|
[submodule "third_party/astrolabe"]
|
||||||
|
path = third_party/astrolabe
|
||||||
|
url = https://github.com/cbcoutinho/astrolabe
|
||||||
|
|||||||
@@ -5,6 +5,78 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.64.2 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## v0.64.1 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## v0.64.0 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add self-signed SSL certificate support for Nextcloud connections
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add type: ignore for caldav ssl_verify_cert parameter
|
||||||
|
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||||
|
|
||||||
|
## v0.63.5 (2026-02-16)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- remove stale astrolabe references from commitizen config
|
||||||
|
- extract Astrolabe to separate repository
|
||||||
|
|
||||||
|
## v0.63.4 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- strip whitespace from category names when splitting
|
||||||
|
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||||
|
|
||||||
|
## v0.63.3 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- expand recurring events in date-range queries
|
||||||
|
|
||||||
|
## v0.63.2 (2026-02-07)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- use CalDAV time-range filter for calendar date range queries
|
||||||
|
|
||||||
|
## v0.63.1 (2026-02-03)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: add backward compatibility for legacy persistence configs
|
||||||
|
|
||||||
|
## v0.63.0 (2026-01-28)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **astrolabe**: add background token refresh job
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||||
|
- **astrolabe**: add locking to prevent token refresh race condition
|
||||||
|
- **astrolabe**: add issued_at to on-demand token refresh
|
||||||
|
|
||||||
## v0.62.0 (2026-01-26)
|
## v0.62.0 (2026-01-26)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
+3
-13
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Version Management
|
## Version Management
|
||||||
|
|
||||||
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
This monorepo uses commitizen for version management with **independent versioning** for two components:
|
||||||
|
|
||||||
### Components
|
### Components
|
||||||
|
|
||||||
@@ -10,7 +10,8 @@ This monorepo uses commitizen for version management with **independent versioni
|
|||||||
|-----------|-------|--------------|-------------|
|
|-----------|-------|--------------|-------------|
|
||||||
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||||
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||||
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
|
||||||
|
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
|
||||||
|
|
||||||
### Commit Message Format
|
### Commit Message Format
|
||||||
|
|
||||||
@@ -24,10 +25,6 @@ fix(mcp): resolve authentication bug
|
|||||||
# Helm chart changes
|
# Helm chart changes
|
||||||
feat(helm): add resource limits
|
feat(helm): add resource limits
|
||||||
docs(helm): update values documentation
|
docs(helm): update values documentation
|
||||||
|
|
||||||
# Astrolabe app changes
|
|
||||||
feat(astrolabe): add dark mode toggle
|
|
||||||
fix(astrolabe): resolve search UI bug
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Unscoped commits** default to the MCP server:
|
**Unscoped commits** default to the MCP server:
|
||||||
@@ -40,7 +37,6 @@ feat: add new feature # → MCP server (v0.54.0)
|
|||||||
#### 1. Make Changes with Scoped Commits
|
#### 1. Make Changes with Scoped Commits
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
|
||||||
git commit -m "feat(helm): add ingress annotations"
|
git commit -m "feat(helm): add ingress annotations"
|
||||||
git commit -m "feat(mcp): add calendar sync"
|
git commit -m "feat(mcp): add calendar sync"
|
||||||
```
|
```
|
||||||
@@ -58,10 +54,6 @@ git commit -m "feat(mcp): add calendar sync"
|
|||||||
# → Creates tag: nextcloud-mcp-server-0.54.0
|
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||||
# → Updates: Chart.yaml:version
|
# → Updates: Chart.yaml:version
|
||||||
|
|
||||||
# Bump Astrolabe (reads commits with scope=astrolabe)
|
|
||||||
./scripts/bump-astrolabe.sh
|
|
||||||
# → Creates tag: astrolabe-v0.2.0
|
|
||||||
# → Updates: info.xml, package.json
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 3. Push Tags
|
#### 3. Push Tags
|
||||||
@@ -76,7 +68,6 @@ Each component maintains its own `CHANGELOG.md`:
|
|||||||
|
|
||||||
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||||
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
|
||||||
|
|
||||||
### Manual Version Bumps
|
### Manual Version Bumps
|
||||||
|
|
||||||
@@ -101,7 +92,6 @@ uv run cz --config .cz.toml bump --increment MINOR
|
|||||||
|
|
||||||
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||||
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||||
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
|
||||||
|
|
||||||
### Chart.yaml Version vs appVersion
|
### Chart.yaml Version vs appVersion
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256: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
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+2
-2
@@ -12,12 +12,12 @@
|
|||||||
# - Per-session app password authentication
|
# - Per-session app password authentication
|
||||||
# - Multi-user support via Smithery session config
|
# - Multi-user support via Smithery session config
|
||||||
|
|
||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:5e2dbd4bbdd9c0e67412aea9463906f74a22c60f89eb7b5bbb7d45b66a2b68a6
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.26@sha256:9a23023be68b2ed09750ae636228e903a54a05ea56ed03a934d00fe9fbeded4b /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -2,35 +2,17 @@
|
|||||||
|
|
||||||
set -euox pipefail
|
set -euox pipefail
|
||||||
|
|
||||||
echo "Installing Astrolabe app for testing..."
|
echo "Installing Astrolabe app from app store..."
|
||||||
|
|
||||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
if [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||||
if [ -d /opt/apps/astrolabe ]; then
|
|
||||||
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
|
||||||
|
|
||||||
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
|
||||||
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
|
||||||
echo "Removing existing astrolabe in custom_apps..."
|
|
||||||
rm -rf /var/www/html/custom_apps/astrolabe
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create symlink from custom_apps to the mounted development version
|
|
||||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
|
||||||
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
|
||||||
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
|
||||||
|
|
||||||
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
|
||||||
php /var/www/html/occ app:enable astrolabe
|
|
||||||
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
|
||||||
echo "astrolabe app directory found in custom_apps (already installed)"
|
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
else
|
else
|
||||||
echo "astrolabe app not found, installing from app store..."
|
|
||||||
php /var/www/html/occ app:install astrolabe
|
php /var/www/html/occ app:install astrolabe
|
||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "✓ Astrolabe app installed successfully"
|
echo "Astrolabe app installed successfully"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Note: MCP server configuration is managed dynamically during tests"
|
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||||
echo " to support testing multiple MCP server deployments."
|
echo " to support testing multiple MCP server deployments."
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.15"
|
version = "0.57.71"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,172 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.69 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.67 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.66 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.65 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.64 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.63 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.62 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.61 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.60 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.59 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.58 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.57 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.56 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.55 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.54 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.53 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.52 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.51 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add self-signed SSL certificate support for Nextcloud connections
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add type: ignore for caldav ssl_verify_cert parameter
|
||||||
|
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.50 (2026-02-16)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- remove stale astrolabe references from commitizen config
|
||||||
|
- extract Astrolabe to separate repository
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.46 (2026-02-12)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.45 (2026-02-12)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.44 (2026-02-11)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.43 (2026-02-11)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.42 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- strip whitespace from category names when splitting
|
||||||
|
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.41 (2026-02-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- expand recurring events in date-range queries
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.40 (2026-02-07)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- use CalDAV time-range filter for calendar date range queries
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.39 (2026-02-07)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.38 (2026-02-07)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.37 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.36 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.35 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.34 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.33 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.32 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.31 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.30 (2026-02-06)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.29 (2026-02-04)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.28 (2026-02-03)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.27 (2026-02-03)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **helm**: add backward compatibility for legacy persistence configs
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.26 (2026-01-31)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.25 (2026-01-31)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.24 (2026-01-31)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.23 (2026-01-30)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.22 (2026-01-30)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.21 (2026-01-30)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.20 (2026-01-29)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.19 (2026-01-28)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.18 (2026-01-28)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.17 (2026-01-28)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.16 (2026-01-28)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- **astrolabe**: add background token refresh job
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **astrolabe**: add pagination and psalm fixes for token refresh
|
||||||
|
- **astrolabe**: add locking to prevent token refresh race condition
|
||||||
|
- **astrolabe**: add issued_at to on-demand token refresh
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
## nextcloud-mcp-server-0.57.15 (2026-01-26)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.16.3
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.38.0
|
version: 1.44.0
|
||||||
digest: sha256:60b09d52759c84f8add5782c867f5a373aa6eb2477dc9380bef0134183c4b1ae
|
digest: sha256:7fcdb59d99ea8662b7d796f769161a036fbf0efd6189935d51f65156966df384
|
||||||
generated: "2026-01-20T11:11:57.230612063Z"
|
generated: "2026-02-20T11:15:18.858058196Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.57.15
|
version: 0.57.71
|
||||||
appVersion: "0.62.0"
|
appVersion: "0.64.2"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -31,6 +31,6 @@ dependencies:
|
|||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.38.0"
|
version: "1.44.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
@@ -118,6 +118,25 @@ ingress:
|
|||||||
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
|
||||||
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
|
||||||
|
|
||||||
|
#### Data Storage
|
||||||
|
|
||||||
|
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
|
||||||
|
|
||||||
|
| Parameter | Description | Default |
|
||||||
|
|-----------|-------------|---------|
|
||||||
|
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
|
||||||
|
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
|
||||||
|
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
|
||||||
|
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
|
||||||
|
| `dataStorage.existingClaim` | Use existing PVC | `""` |
|
||||||
|
|
||||||
|
**When to enable persistence:**
|
||||||
|
- Multi-user basic auth with offline access (stores `tokens.db`)
|
||||||
|
- Qdrant persistent mode (stores vector database)
|
||||||
|
- Any feature requiring persistent app data
|
||||||
|
|
||||||
|
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
|
||||||
|
|
||||||
#### MCP Server Configuration
|
#### MCP Server Configuration
|
||||||
|
|
||||||
| Parameter | Description | Default |
|
| Parameter | Description | Default |
|
||||||
|
|||||||
@@ -120,6 +120,55 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
|
|||||||
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||||
|
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||||
|
{{- if or $legacyMultiUserBasic $legacyQdrant }}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
DEPRECATION WARNING
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
You are using deprecated persistence configuration that will be removed in a
|
||||||
|
future release. Your deployment will continue to work, but please migrate to
|
||||||
|
the new unified dataStorage configuration.
|
||||||
|
|
||||||
|
Deprecated settings detected:
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
- auth.multiUserBasic.persistence.* (currently enabled)
|
||||||
|
{{- end }}
|
||||||
|
{{- if $legacyQdrant }}
|
||||||
|
- qdrant.localPersistence.* (currently enabled)
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
To migrate, update your values.yaml:
|
||||||
|
|
||||||
|
dataStorage:
|
||||||
|
enabled: true
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
size: {{ .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- else if $legacyQdrant }}
|
||||||
|
size: {{ .Values.qdrant.localPersistence.size }}
|
||||||
|
{{- end }}
|
||||||
|
# storageClass: "" # Optional: specify storage class
|
||||||
|
# existingClaim: "" # Optional: use existing PVC to preserve data
|
||||||
|
|
||||||
|
After migrating, remove the deprecated settings:
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
- auth.multiUserBasic.persistence.enabled
|
||||||
|
- auth.multiUserBasic.persistence.size
|
||||||
|
- auth.multiUserBasic.persistence.storageClass
|
||||||
|
- auth.multiUserBasic.persistence.accessMode
|
||||||
|
{{- end }}
|
||||||
|
{{- if $legacyQdrant }}
|
||||||
|
- qdrant.localPersistence.enabled
|
||||||
|
- qdrant.localPersistence.size
|
||||||
|
- qdrant.localPersistence.storageClass
|
||||||
|
- qdrant.localPersistence.accessMode
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
================================================================================
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
For more information and documentation:
|
For more information and documentation:
|
||||||
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
|
||||||
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
|
||||||
|
|||||||
@@ -127,6 +127,55 @@ Create the name of the PVC to use for Qdrant local persistent storage
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Create the name of the PVC to use for /app/data storage
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
|
||||||
|
{{- if .Values.dataStorage.existingClaim }}
|
||||||
|
{{- .Values.dataStorage.existingClaim }}
|
||||||
|
{{- else }}
|
||||||
|
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Determine if data storage PVC should be enabled (backward compatible)
|
||||||
|
Checks new dataStorage.enabled OR legacy persistence configs
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
|
||||||
|
{{- if .Values.dataStorage.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Check if legacy multi-user-basic persistence config is being used
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
|
||||||
|
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{/*
|
||||||
|
Check if legacy qdrant persistence config is being used
|
||||||
|
*/}}
|
||||||
|
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
|
||||||
|
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
|
||||||
|
true
|
||||||
|
{{- else -}}
|
||||||
|
false
|
||||||
|
{{- end -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
{{/*
|
{{/*
|
||||||
Return the MCP server port
|
Return the MCP server port
|
||||||
*/}}
|
*/}}
|
||||||
|
|||||||
@@ -286,14 +286,8 @@ spec:
|
|||||||
- name: oauth-storage
|
- name: oauth-storage
|
||||||
mountPath: /app/.oauth
|
mountPath: /app/.oauth
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
- name: data-storage
|
||||||
- name: token-storage
|
|
||||||
mountPath: /app/data
|
mountPath: /app/data
|
||||||
{{- end }}
|
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
|
||||||
- name: qdrant-data
|
|
||||||
mountPath: /app/data
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.volumeMounts }}
|
{{- with .Values.volumeMounts }}
|
||||||
{{- toYaml . | nindent 12 }}
|
{{- toYaml . | nindent 12 }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
@@ -305,15 +299,12 @@ spec:
|
|||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled }}
|
- name: data-storage
|
||||||
- name: token-storage
|
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
claimName: {{ include "nextcloud-mcp-server.multiUserBasicPvcName" . }}
|
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
|
||||||
{{- end }}
|
{{- else }}
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
|
emptyDir: {}
|
||||||
- name: qdrant-data
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
{{- with .Values.volumes }}
|
{{- with .Values.volumes }}
|
||||||
{{- toYaml . | nindent 8 }}
|
{{- toYaml . | nindent 8 }}
|
||||||
|
|||||||
@@ -16,38 +16,34 @@ spec:
|
|||||||
storage: {{ .Values.auth.oauth.persistence.size }}
|
storage: {{ .Values.auth.oauth.persistence.size }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
---
|
---
|
||||||
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.auth.multiUserBasic.persistence.existingClaim) }}
|
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
|
||||||
|
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
|
||||||
|
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
|
||||||
|
{{- $accessMode := .Values.dataStorage.accessMode }}
|
||||||
|
{{- $storageClass := .Values.dataStorage.storageClass }}
|
||||||
|
{{- $size := .Values.dataStorage.size }}
|
||||||
|
{{- if $legacyMultiUserBasic }}
|
||||||
|
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
|
||||||
|
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
|
||||||
|
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
|
||||||
|
{{- else if $legacyQdrant }}
|
||||||
|
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
|
||||||
|
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
|
||||||
|
{{- $size = .Values.qdrant.localPersistence.size }}
|
||||||
|
{{- end }}
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: PersistentVolumeClaim
|
kind: PersistentVolumeClaim
|
||||||
metadata:
|
metadata:
|
||||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-token-storage
|
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
|
||||||
labels:
|
labels:
|
||||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
||||||
spec:
|
spec:
|
||||||
accessModes:
|
accessModes:
|
||||||
- {{ .Values.auth.multiUserBasic.persistence.accessMode }}
|
- {{ $accessMode }}
|
||||||
{{- if .Values.auth.multiUserBasic.persistence.storageClass }}
|
{{- if $storageClass }}
|
||||||
storageClassName: {{ .Values.auth.multiUserBasic.persistence.storageClass }}
|
storageClassName: {{ $storageClass }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
storage: {{ .Values.auth.multiUserBasic.persistence.size }}
|
storage: {{ $size }}
|
||||||
{{- end }}
|
|
||||||
---
|
|
||||||
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
|
|
||||||
apiVersion: v1
|
|
||||||
kind: PersistentVolumeClaim
|
|
||||||
metadata:
|
|
||||||
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
|
|
||||||
labels:
|
|
||||||
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
|
|
||||||
spec:
|
|
||||||
accessModes:
|
|
||||||
- {{ .Values.qdrant.localPersistence.accessMode }}
|
|
||||||
{{- if .Values.qdrant.localPersistence.storageClass }}
|
|
||||||
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
storage: {{ .Values.qdrant.localPersistence.size }}
|
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -139,6 +139,27 @@ auth:
|
|||||||
# Use existing PVC
|
# Use existing PVC
|
||||||
existingClaim: ""
|
existingClaim: ""
|
||||||
|
|
||||||
|
# Data Storage Configuration
|
||||||
|
# Persistent volume for /app/data directory
|
||||||
|
# Used for: token databases, qdrant persistent storage, and any app data
|
||||||
|
# When disabled, uses emptyDir (non-persistent, but still writable)
|
||||||
|
dataStorage:
|
||||||
|
# Enable persistent storage for /app/data
|
||||||
|
# Set to true when using:
|
||||||
|
# - Multi-user basic auth with offline access (stores tokens.db)
|
||||||
|
# - Qdrant persistent mode (stores vector database)
|
||||||
|
# - Any feature requiring persistent app data
|
||||||
|
# Set to false for basic auth without persistence (uses emptyDir)
|
||||||
|
enabled: false
|
||||||
|
# Storage class (leave empty for default)
|
||||||
|
storageClass: ""
|
||||||
|
accessMode: ReadWriteOnce
|
||||||
|
# Size for data storage (should accommodate tokens.db and/or qdrant data)
|
||||||
|
# Recommended: 1Gi minimum, 5Gi for production with qdrant
|
||||||
|
size: 1Gi
|
||||||
|
# Use existing PVC
|
||||||
|
existingClaim: ""
|
||||||
|
|
||||||
# MCP server configuration
|
# MCP server configuration
|
||||||
mcp:
|
mcp:
|
||||||
# Transport mode (default: streamable-http for SSE)
|
# Transport mode (default: streamable-http for SSE)
|
||||||
|
|||||||
+7
-8
@@ -3,7 +3,7 @@ services:
|
|||||||
# https://hub.docker.com/_/mariadb
|
# https://hub.docker.com/_/mariadb
|
||||||
db:
|
db:
|
||||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||||
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
|
image: docker.io/library/mariadb:lts@sha256:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
|
||||||
restart: always
|
restart: always
|
||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
@@ -19,11 +19,11 @@ services:
|
|||||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||||
# https://hub.docker.com/_/redis
|
# https://hub.docker.com/_/redis
|
||||||
redis:
|
redis:
|
||||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
image: docker.io/library/redis:alpine@sha256:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.5@sha256:11a3a4f63bad8813c7455b4a3c473ccd1c41e2c48f55decb51718f15691e7568
|
image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8080:80
|
- 127.0.0.1:8080:80
|
||||||
@@ -37,7 +37,6 @@ services:
|
|||||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||||
# The post-installation hook will register /opt/apps as an additional app directory
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
#- ./third_party:/opt/apps:ro
|
#- ./third_party:/opt/apps:ro
|
||||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -54,14 +53,14 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:66d420cc54ef85bcc1d72220e83d7aaa6c4850bd2904794e3a56f09fd4ccb66e
|
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
|
||||||
unstructured:
|
unstructured:
|
||||||
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:db5fcc831eb673ec835c41e8d47f993fdde276562285d6837cebb03f958536a2
|
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8002:8000
|
- 127.0.0.1:8002:8000
|
||||||
@@ -208,7 +207,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
image: quay.io/keycloak/keycloak:26.5.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -295,7 +294,7 @@ services:
|
|||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
## Configuration Quick Reference
|
||||||
|
|
||||||
### Single-User BasicAuth
|
### Single-User BasicAuth
|
||||||
|
|||||||
+1
-46
@@ -225,52 +225,7 @@ NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
|||||||
|
|
||||||
### Astrolabe User Setup (Hybrid Mode)
|
### Astrolabe User Setup (Hybrid Mode)
|
||||||
|
|
||||||
When Astrolabe connects to an MCP server running in hybrid mode, users must complete a **two-step credential setup**:
|
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).
|
||||||
|
|
||||||
#### 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
|
|
||||||
|
|
||||||
### See Also
|
### See Also
|
||||||
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
|
||||||
|
|||||||
+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)
|
## Semantic Search Configuration (Optional)
|
||||||
|
|
||||||
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||||
@@ -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
|
## Loading Environment Variables
|
||||||
|
|
||||||
After creating your `.env` file, load the 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:
|
Before enabling webhooks, ensure:
|
||||||
|
|
||||||
1. **Nextcloud 30+** with `webhook_listeners` app enabled
|
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)
|
3. **MCP server** accessible from Nextcloud via HTTP(S)
|
||||||
4. **Vector sync enabled** on the MCP server
|
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"
|
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
|
## Security Considerations
|
||||||
|
|
||||||
### Webhook Authentication
|
### Webhook Authentication
|
||||||
@@ -327,7 +309,7 @@ SELECT * FROM oc_webhook_listeners;
|
|||||||
-- Check OAuth clients
|
-- Check OAuth clients
|
||||||
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
|
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';
|
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+13
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
|
|||||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||||
|
|
||||||
|
# ===== SSL/TLS =====
|
||||||
|
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
|
||||||
|
#
|
||||||
|
# Disable TLS certificate verification (insecure, development only):
|
||||||
|
#NEXTCLOUD_VERIFY_SSL=false
|
||||||
|
#
|
||||||
|
# Use a custom CA bundle (path to PEM file):
|
||||||
|
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||||
|
#
|
||||||
|
# Docker example: mount the CA bundle as a volume
|
||||||
|
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||||
|
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
|
||||||
|
|
||||||
# ===== SECURITY & ADVANCED =====
|
# ===== SECURITY & ADVANCED =====
|
||||||
# Cookie security (browser UI)
|
# Cookie security (browser UI)
|
||||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||||
|
|||||||
@@ -20,9 +20,15 @@ import time
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.models import Filter
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,16 +202,12 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
# Public endpoint - no authentication required
|
# Public endpoint - no authentication required
|
||||||
|
|
||||||
# Get configuration
|
# Get configuration
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Calculate uptime
|
# Calculate uptime
|
||||||
uptime_seconds = int(time.time() - _server_start_time)
|
uptime_seconds = int(time.time() - _server_start_time)
|
||||||
|
|
||||||
# Determine auth mode using proper mode detection
|
# Determine auth mode using proper mode detection
|
||||||
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
|
||||||
|
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
# Map deployment mode to auth_mode for API response
|
# 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
|
# Public endpoint - no authentication required
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -299,11 +299,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import Filter
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection, excluding placeholders
|
# Count documents in collection, excluding placeholders
|
||||||
@@ -375,8 +370,6 @@ async def get_user_session(request: Request) -> JSONResponse:
|
|||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
enable_offline_access = settings.enable_offline_access
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
|
|||||||
@@ -15,16 +15,16 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@ def _extract_basic_auth(
|
|||||||
return username, password, None
|
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.
|
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||||
|
|
||||||
Checks app.state.storage first, then falls back to creating from environment.
|
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:
|
Returns:
|
||||||
Initialized RefreshTokenStorage instance
|
Initialized RefreshTokenStorage instance
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = getattr(request.app.state, "storage", None)
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
if not storage:
|
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
|
- Only the user who owns the password can provision it
|
||||||
- Rate limited to prevent brute-force attacks
|
- Rate limited to prevent brute-force attacks
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
# Get user_id from path
|
# Get user_id from path
|
||||||
path_user_id = request.path_params.get("user_id")
|
path_user_id = request.path_params.get("user_id")
|
||||||
if not path_user_id:
|
if not path_user_id:
|
||||||
@@ -252,7 +248,9 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
# Validate app password against Nextcloud
|
# Validate app password against Nextcloud
|
||||||
try:
|
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
|
# Use OCS API to verify credentials
|
||||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -360,8 +358,6 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires BasicAuth with the user's credentials.
|
Requires BasicAuth with the user's credentials.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
# Get user_id from path
|
# Get user_id from path
|
||||||
path_user_id = request.path_params.get("user_id")
|
path_user_id = request.path_params.get("user_id")
|
||||||
if not path_user_id:
|
if not path_user_id:
|
||||||
@@ -380,7 +376,9 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
|||||||
nextcloud_host = settings.nextcloud_host
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
try:
|
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"
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
test_url,
|
test_url,
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -29,6 +26,17 @@ from nextcloud_mcp_server.api.management import (
|
|||||||
extract_bearer_token,
|
extract_bearer_token,
|
||||||
validate_token_and_get_user,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -68,8 +76,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires OAuth bearer token for user filtering.
|
Requires OAuth bearer token for user filtering.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -144,12 +150,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
if fusion not in valid_fusions:
|
if fusion not in valid_fusions:
|
||||||
fusion = "rrf"
|
fusion = "rrf"
|
||||||
|
|
||||||
# Execute search using the appropriate algorithm
|
|
||||||
from nextcloud_mcp_server.search import (
|
|
||||||
BM25HybridSearchAlgorithm,
|
|
||||||
SemanticSearchAlgorithm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select search algorithm
|
# Select search algorithm
|
||||||
if algorithm == "semantic":
|
if algorithm == "semantic":
|
||||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||||
@@ -254,17 +254,9 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
# Optional PCA coordinates
|
# Optional PCA coordinates
|
||||||
if include_pca and len(paginated_results) >= 2:
|
if include_pca and len(paginated_results) >= 2:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.vector.visualization import (
|
|
||||||
compute_pca_coordinates,
|
|
||||||
)
|
|
||||||
|
|
||||||
if search_algo.query_embedding is not None:
|
if search_algo.query_embedding is not None:
|
||||||
query_embedding = search_algo.query_embedding
|
query_embedding = search_algo.query_embedding
|
||||||
else:
|
else:
|
||||||
from nextcloud_mcp_server.embedding.service import (
|
|
||||||
get_embedding_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
@@ -305,8 +297,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires OAuth bearer token for user filtering.
|
Requires OAuth bearer token for user filtering.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -354,12 +344,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
if fusion not in valid_fusions:
|
if fusion not in valid_fusions:
|
||||||
fusion = "rrf"
|
fusion = "rrf"
|
||||||
|
|
||||||
# Execute search using the appropriate algorithm
|
|
||||||
from nextcloud_mcp_server.search import (
|
|
||||||
BM25HybridSearchAlgorithm,
|
|
||||||
SemanticSearchAlgorithm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select search algorithm
|
# Select search algorithm
|
||||||
if algorithm == "semantic":
|
if algorithm == "semantic":
|
||||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
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
|
# Compute PCA coordinates for visualization using shared function
|
||||||
if include_pca and len(all_results) >= 2:
|
if include_pca and len(all_results) >= 2:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.vector.visualization import (
|
|
||||||
compute_pca_coordinates,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get query embedding from search algorithm or generate it
|
# Get query embedding from search algorithm or generate it
|
||||||
if search_algo.query_embedding is not None:
|
if search_algo.query_embedding is not None:
|
||||||
query_embedding = search_algo.query_embedding
|
query_embedding = search_algo.query_embedding
|
||||||
else:
|
else:
|
||||||
from nextcloud_mcp_server.embedding.service import (
|
|
||||||
get_embedding_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
@@ -549,9 +525,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Initialize authenticated Nextcloud client
|
# 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(
|
async with NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host, token=token, username=user_id
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
) as nc_client:
|
) as nc_client:
|
||||||
@@ -581,14 +554,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
if doc_type == "file":
|
if doc_type == "file":
|
||||||
try:
|
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()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
@@ -735,8 +700,6 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Download PDF via WebDAV using user's token
|
# Download PDF via WebDAV using user's token
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
|
||||||
|
|
||||||
async with NextcloudClient.from_token(
|
async with NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host, token=token, username=user_id
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
) as nc_client:
|
) as nc_client:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -19,6 +18,9 @@ from nextcloud_mcp_server.api.management import (
|
|||||||
extract_bearer_token,
|
extract_bearer_token,
|
||||||
validate_token_and_get_user,
|
validate_token_and_get_user,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ async def get_installed_apps(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -114,8 +116,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Get Bearer token from request
|
# Get Bearer token from request
|
||||||
token = extract_bearer_token(request)
|
token = extract_bearer_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
@@ -129,7 +129,7 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -179,8 +179,6 @@ async def create_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Parse request body
|
# Parse request body
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
event = body.get("event")
|
event = body.get("event")
|
||||||
@@ -210,7 +208,7 @@ async def create_webhook(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -255,8 +253,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Get webhook_id from path parameter
|
# Get webhook_id from path parameter
|
||||||
webhook_id = request.path_params.get("webhook_id")
|
webhook_id = request.path_params.get("webhook_id")
|
||||||
if not webhook_id:
|
if not webhook_id:
|
||||||
@@ -286,7 +282,7 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
|||||||
+77
-94
@@ -10,22 +10,17 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
from typing import Optional, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.server.transport_security import TransportSecuritySettings
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
@@ -36,6 +31,23 @@ from starlette.staticfiles import StaticFiles
|
|||||||
from starlette.types import ASGIApp, Receive, Send
|
from starlette.types import ASGIApp, Receive, Send
|
||||||
from starlette.types import Scope as StarletteScope
|
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 (
|
from nextcloud_mcp_server.auth import (
|
||||||
InsufficientScopeError,
|
InsufficientScopeError,
|
||||||
discover_all_scopes,
|
discover_all_scopes,
|
||||||
@@ -43,7 +55,38 @@ from nextcloud_mcp_server.auth import (
|
|||||||
has_required_scopes,
|
has_required_scopes,
|
||||||
is_jwt_token,
|
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.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.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
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.context import get_client as get_nextcloud_client
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
|
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
||||||
from nextcloud_mcp_server.observability import (
|
from nextcloud_mcp_server.observability import (
|
||||||
ObservabilityMiddleware,
|
ObservabilityMiddleware,
|
||||||
setup_metrics,
|
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.server.oauth_tools import register_oauth_tools
|
||||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
HTTPXClientInstrumentor().instrument()
|
HTTPXClientInstrumentor().instrument()
|
||||||
@@ -105,7 +154,7 @@ def initialize_document_processors():
|
|||||||
if "unstructured" in config["processors"]:
|
if "unstructured" in config["processors"]:
|
||||||
unst_config = config["processors"]["unstructured"]
|
unst_config = config["processors"]["unstructured"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
from nextcloud_mcp_server.document_processors.unstructured import ( # noqa: PLC0415
|
||||||
UnstructuredProcessor,
|
UnstructuredProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,7 +175,7 @@ def initialize_document_processors():
|
|||||||
if "tesseract" in config["processors"]:
|
if "tesseract" in config["processors"]:
|
||||||
tess_config = config["processors"]["tesseract"]
|
tess_config = config["processors"]["tesseract"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.tesseract import (
|
from nextcloud_mcp_server.document_processors.tesseract import ( # noqa: PLC0415
|
||||||
TesseractProcessor,
|
TesseractProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -144,7 +193,7 @@ def initialize_document_processors():
|
|||||||
if "pymupdf" in config["processors"]:
|
if "pymupdf" in config["processors"]:
|
||||||
pymupdf_config = config["processors"]["pymupdf"]
|
pymupdf_config = config["processors"]["pymupdf"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.pymupdf import (
|
from nextcloud_mcp_server.document_processors.pymupdf import ( # noqa: PLC0415
|
||||||
PyMuPDFProcessor,
|
PyMuPDFProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,7 +213,7 @@ def initialize_document_processors():
|
|||||||
if "custom" in config["processors"]:
|
if "custom" in config["processors"]:
|
||||||
custom_config = config["processors"]["custom"]
|
custom_config = config["processors"]["custom"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.custom_http import (
|
from nextcloud_mcp_server.document_processors.custom_http import ( # noqa: PLC0415
|
||||||
CustomHTTPProcessor,
|
CustomHTTPProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -430,8 +479,6 @@ class SmitheryConfigMiddleware:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if scope["type"] == "http":
|
if scope["type"] == "http":
|
||||||
# Extract config from query parameters
|
# Extract config from query parameters
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||||
params = parse_qs(query_string)
|
params = parse_qs(query_string)
|
||||||
|
|
||||||
@@ -506,8 +553,6 @@ async def load_oauth_client_credentials(
|
|||||||
|
|
||||||
# Try loading from SQLite storage
|
# Try loading from SQLite storage
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
|
||||||
@@ -558,9 +603,6 @@ async def load_oauth_client_credentials(
|
|||||||
logger.info(f"Requesting token type: {token_type}")
|
logger.info(f"Requesting token type: {token_type}")
|
||||||
|
|
||||||
# Ensure OAuth client in SQLite storage
|
# 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()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
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)
|
# Initialize persistent storage (for webhook tracking and future features)
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
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}")
|
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||||
|
|
||||||
# Perform OIDC discovery
|
# 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 = await client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -755,10 +795,6 @@ async def setup_oauth_config():
|
|||||||
refresh_token_storage = None
|
refresh_token_storage = None
|
||||||
if enable_offline_access:
|
if enable_offline_access:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import (
|
|
||||||
RefreshTokenStorage,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate encryption key before initializing
|
# Validate encryption key before initializing
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
if not encryption_key:
|
if not encryption_key:
|
||||||
@@ -880,8 +916,6 @@ async def setup_oauth_config():
|
|||||||
oauth_client = None
|
oauth_client = None
|
||||||
if enable_offline_access and refresh_token_storage and is_external_idp:
|
if enable_offline_access and refresh_token_storage and is_external_idp:
|
||||||
# For external IdP mode, create generic OIDC client for token operations
|
# 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")
|
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
|
# 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)
|
# 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
|
# Perform OIDC discovery
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
timeout=30.0, follow_redirects=True
|
timeout=30.0, follow_redirects=True
|
||||||
) as http_client:
|
) as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
@@ -1076,8 +1110,6 @@ async def setup_oauth_config_for_multi_user_basic(
|
|||||||
refresh_token_storage = None
|
refresh_token_storage = None
|
||||||
if settings.enable_offline_access:
|
if settings.enable_offline_access:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
if not encryption_key:
|
if not encryption_key:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1543,8 +1575,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# BasicAuth mode - initialize storage for webhook management
|
# BasicAuth mode - initialize storage for webhook management
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
basic_auth_storage = RefreshTokenStorage.from_env()
|
basic_auth_storage = RefreshTokenStorage.from_env()
|
||||||
await basic_auth_storage.initialize()
|
await basic_auth_storage.initialize()
|
||||||
logger.info("Initialized refresh token storage for webhook management")
|
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
|
# Initialize Qdrant collection before starting background tasks
|
||||||
logger.info("Initializing Qdrant collection...")
|
logger.info("Initializing Qdrant collection...")
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await get_qdrant_client() # Triggers collection creation if needed
|
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"
|
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
||||||
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
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)
|
# Get nextcloud_host (from settings - already validated)
|
||||||
nextcloud_host_for_sync = settings.nextcloud_host
|
nextcloud_host_for_sync = settings.nextcloud_host
|
||||||
if not nextcloud_host_for_sync:
|
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
|
# Initialize Qdrant collection before starting background tasks
|
||||||
logger.info("Initializing Qdrant collection...")
|
logger.info("Initializing Qdrant collection...")
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await get_qdrant_client() # Triggers collection creation if needed
|
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}"
|
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||||
) from 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
|
# Initialize shared state
|
||||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||||
max_buffer_size=settings.vector_sync_queue_max_size
|
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
|
# Try to connect to Nextcloud
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
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")
|
response = await client.get(f"{nextcloud_host}/status.php")
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
if response.status_code == 200:
|
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
|
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||||
)
|
)
|
||||||
if enable_management_apis:
|
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("/api/v1/status", get_server_status, methods=["GET"]))
|
||||||
routes.append(
|
routes.append(
|
||||||
Route(
|
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 provisioning routes enabled for mode: {mode.value} "
|
||||||
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
|
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):
|
def oauth_protected_resource_metadata(request):
|
||||||
"""RFC 9728 Protected Resource Metadata endpoint.
|
"""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
|
# 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"]))
|
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
|
"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
|
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
|
||||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
|
||||||
|
|
||||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
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
|
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
||||||
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
|
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
|
||||||
if oauth_provisioning_available:
|
if oauth_provisioning_available:
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|
||||||
oauth_login,
|
|
||||||
oauth_login_callback,
|
|
||||||
oauth_logout,
|
|
||||||
)
|
|
||||||
|
|
||||||
routes.append(
|
routes.append(
|
||||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
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)
|
# 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)
|
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
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
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
browser_routes = [
|
browser_routes = [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
|||||||
# Discover token endpoint
|
# Discover token endpoint
|
||||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with nextcloud_httpx_client() as client:
|
||||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||||
discovery_resp = await client.get(discovery_url)
|
discovery_resp = await client.get(discovery_url)
|
||||||
discovery_resp.raise_for_status()
|
discovery_resp.raise_for_status()
|
||||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
|||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with nextcloud_httpx_client() as client:
|
||||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
@@ -22,6 +23,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
|||||||
_query_idp_userinfo,
|
_query_idp_userinfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint
|
# Fetch authorization endpoint
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -151,8 +154,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# Replace internal Docker hostname with public URL
|
# Replace internal Docker hostname with public URL
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
@@ -286,7 +287,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
oauth_client.token_endpoint,
|
oauth_client.token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
@@ -296,7 +297,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
else:
|
else:
|
||||||
# Integrated mode (Nextcloud OIDC)
|
# Integrated mode (Nextcloud OIDC)
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -314,7 +315,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import httpx
|
|||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ async def register_client(
|
|||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
registration_endpoint,
|
registration_endpoint,
|
||||||
@@ -229,7 +231,7 @@ async def delete_client(
|
|||||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
async with nextcloud_httpx_client(timeout=30.0) as http_client:
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# Prefer RFC 7592 Bearer token authentication
|
# Prefer RFC 7592 Bearer token authentication
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -161,8 +162,6 @@ class ClientRegistry:
|
|||||||
True if valid, False otherwise
|
True if valid, False otherwise
|
||||||
"""
|
"""
|
||||||
# Parse the redirect URI
|
# Parse the redirect URI
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
parsed = urlparse(redirect_uri)
|
parsed = urlparse(redirect_uri)
|
||||||
|
|
||||||
# Check against registered patterns
|
# Check against registered patterns
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client"""
|
"""Get or create HTTP client"""
|
||||||
if self._http_client is None:
|
if self._http_client is None:
|
||||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
self._http_client = nextcloud_httpx_client(timeout=30.0)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
|||||||
@@ -26,15 +26,18 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +221,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint from discovery
|
# Fetch authorization endpoint from discovery
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -227,8 +230,6 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
# Parse internal and authorization endpoint to compare hostnames
|
# Parse internal and authorization endpoint to compare hostnames
|
||||||
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -363,8 +364,6 @@ async def oauth_authorize_nextcloud(
|
|||||||
# Fix internal hostname for browser access
|
# Fix internal hostname for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
@@ -462,7 +461,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -482,7 +481,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
@@ -566,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
return HTMLResponse(content=success_html, status_code=200)
|
return HTMLResponse(content=success_html, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@@ -632,10 +629,6 @@ async def oauth_callback(request: Request):
|
|||||||
elif flow_type == "browser":
|
elif flow_type == "browser":
|
||||||
# Browser UI Login - establish browser session for /user/page access
|
# Browser UI Login - establish browser session for /user/page access
|
||||||
logger.info("Routing to browser login flow")
|
logger.info("Routing to browser login flow")
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|
||||||
oauth_login_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await oauth_login_callback(request)
|
return await oauth_login_callback(request)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from mcp.shared.exceptions import McpError
|
|||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,8 +67,6 @@ def require_provisioning(func: Callable) -> Callable:
|
|||||||
|
|
||||||
# Check if we're in token exchange mode - if so, skip provisioning check
|
# Check if we're in token exchange mode - if so, skip provisioning check
|
||||||
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||||
# Token exchange mode - per-request exchange, no provisioning needed
|
# Token exchange mode - per-request exchange, no provisioning needed
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from mcp.server.auth.provider import AccessToken
|
|||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,8 +134,6 @@ def require_scopes(*required_scopes: str):
|
|||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
enable_offline_access = settings.enable_offline_access
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
from anyio import to_thread
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||||
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -164,10 +168,6 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
# Run migrations in a worker thread using anyio.to_thread
|
# Run migrations in a worker thread using anyio.to_thread
|
||||||
# This allows Alembic to run its own async operations in a separate context
|
# This allows Alembic to run its own async operations in a separate context
|
||||||
from anyio import to_thread
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
|
||||||
|
|
||||||
if not has_alembic:
|
if not has_alembic:
|
||||||
if has_schema:
|
if has_schema:
|
||||||
# Stamp existing database without running migrations
|
# Stamp existing database without running migrations
|
||||||
@@ -1414,6 +1414,69 @@ class RefreshTokenStorage:
|
|||||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||||
return user_ids
|
return user_ids
|
||||||
|
|
||||||
|
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Validate stored app passwords against Nextcloud and remove invalid ones.
|
||||||
|
|
||||||
|
Makes a lightweight OCS request for each stored user to check if credentials
|
||||||
|
are still valid. Removes entries that return 401/403.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs whose app passwords were removed
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
user_ids = await self.get_all_app_password_user_ids()
|
||||||
|
if not user_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
removed: list[str] = []
|
||||||
|
|
||||||
|
async def _validate_user(user_id: str) -> None:
|
||||||
|
app_password = await self.get_app_password(user_id)
|
||||||
|
if not app_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
auth=httpx.BasicAuth(user_id, app_password),
|
||||||
|
timeout=10.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/ocs/v2.php/cloud/user",
|
||||||
|
headers={
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
logger.info(
|
||||||
|
f"App password for {user_id} is invalid "
|
||||||
|
f"(HTTP {response.status_code}), removing"
|
||||||
|
)
|
||||||
|
await self.delete_app_password(user_id)
|
||||||
|
removed.append(user_id)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"App password for {user_id} validated "
|
||||||
|
f"(HTTP {response.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not validate app password for {user_id}: {e}")
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for user_id in user_ids:
|
||||||
|
tg.start_soon(_validate_user, user_id)
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
async def generate_encryption_key() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import jwt
|
|||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client."""
|
"""Get or create HTTP client."""
|
||||||
if self._http_client is None:
|
if self._http_client is None:
|
||||||
self._http_client = httpx.AsyncClient(
|
self._http_client = nextcloud_httpx_client(
|
||||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||||
)
|
)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
from .storage import RefreshTokenStorage
|
from .storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
|||||||
self.storage: Optional[RefreshTokenStorage] = None
|
self.storage: Optional[RefreshTokenStorage] = None
|
||||||
|
|
||||||
# Create HTTP client
|
# Create HTTP client
|
||||||
self.http_client = httpx.AsyncClient(
|
self.http_client = nextcloud_httpx_client(
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
record_oauth_token_validation,
|
record_oauth_token_validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||||
|
|
||||||
# Common components for all modes
|
# Common components for all modes
|
||||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||||
|
|
||||||
# JWT verification support
|
# JWT verification support
|
||||||
self.jwks_client: PyJWKClient | None = None
|
self.jwks_client: PyJWKClient | None = None
|
||||||
|
|||||||
@@ -13,15 +13,18 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
from httpx import BasicAuth
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
from starlette.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Setup Jinja2 environment for templates
|
# 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]):
|
if not all([nextcloud_host, username, password]):
|
||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
from httpx import BasicAuth
|
|
||||||
|
|
||||||
assert nextcloud_host is not None
|
assert nextcloud_host is not None
|
||||||
assert username is not None
|
assert username is not None
|
||||||
assert password is not None
|
assert password is not None
|
||||||
@@ -128,7 +129,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.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()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
@@ -257,7 +260,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||||
response = await client.get(discovery_url)
|
response = await client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -290,7 +293,7 @@ async def _query_idp_userinfo(
|
|||||||
User info dictionary from IdP, or None if query fails
|
User info dictionary from IdP, or None if query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
userinfo_uri,
|
userinfo_uri,
|
||||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||||
@@ -430,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Check if user is admin (for Webhooks tab)
|
# Check if user is admin (for Webhooks tab)
|
||||||
is_admin = False
|
is_admin = False
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# Get authenticated Nextcloud client
|
||||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||||
@@ -470,8 +471,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Get Nextcloud host for generating links to apps (used by viz tab)
|
# Get Nextcloud host for generating links to apps (used by viz tab)
|
||||||
# Use public issuer URL if available (for browser-accessible links),
|
# Use public issuer URL if available (for browser-accessible links),
|
||||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
nextcloud_host_for_links = (
|
nextcloud_host_for_links = (
|
||||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
||||||
|
|||||||
@@ -18,16 +18,22 @@ from pathlib import Path
|
|||||||
import anyio
|
import anyio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
from starlette.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
|
_get_authenticated_client_for_userinfo,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
from nextcloud_mcp_server.search import (
|
from nextcloud_mcp_server.search import (
|
||||||
BM25HybridSearchAlgorithm,
|
BM25HybridSearchAlgorithm,
|
||||||
SemanticSearchAlgorithm,
|
SemanticSearchAlgorithm,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
from nextcloud_mcp_server.vector.pca import PCA
|
from nextcloud_mcp_server.vector.pca import PCA
|
||||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
@@ -137,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
# Get authenticated HTTP client from session
|
# Get authenticated HTTP client from session
|
||||||
# In BasicAuth mode: uses username/password from session
|
# In BasicAuth mode: uses username/password from session
|
||||||
# In OAuth mode: uses access token from session
|
# In OAuth mode: uses access token from session
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
|
||||||
_get_authenticated_client_for_userinfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
with trace_operation("vector_viz.get_auth_client"):
|
with trace_operation("vector_viz.get_auth_client"):
|
||||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||||
|
|
||||||
@@ -353,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: generate embedding if not available from search
|
# Fallback: generate embedding if not available from search
|
||||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||||
@@ -555,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
doc_id_int = int(doc_id)
|
doc_id_int = int(doc_id)
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# Get authenticated Nextcloud client
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
|
||||||
_get_authenticated_client_for_userinfo,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
|
||||||
|
|
||||||
# Use context expansion module to fetch chunk with surrounding context
|
# Use context expansion module to fetch chunk with surrounding context
|
||||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||||
chunk_context = await get_chunk_with_context(
|
chunk_context = await get_chunk_with_context(
|
||||||
@@ -594,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
page_number = None
|
page_number = None
|
||||||
if doc_type == "file":
|
if doc_type == "file":
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
username = request.user.display_name
|
username = request.user.display_name
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
|||||||
get_preset,
|
get_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
|
|
||||||
assert nextcloud_host is not None # Type narrowing for type checker
|
assert nextcloud_host is not None # Type narrowing for type checker
|
||||||
assert username is not None and password is not None # Type narrowing
|
assert username is not None and password is not None # Type narrowing
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
raise RuntimeError("Nextcloud host not configured")
|
raise RuntimeError("Nextcloud host not configured")
|
||||||
|
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import uvicorn
|
|||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
get_settings,
|
get_settings,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.migrations import (
|
||||||
|
create_migration,
|
||||||
|
downgrade_database,
|
||||||
|
get_current_revision,
|
||||||
|
show_migration_history,
|
||||||
|
upgrade_database,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||||
|
|
||||||
from .app import get_app
|
from .app import get_app
|
||||||
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
|
|||||||
# Use custom database path
|
# Use custom database path
|
||||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import upgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Upgrading database to revision: {revision}")
|
click.echo(f"Upgrading database to revision: {revision}")
|
||||||
upgrade_database(database_path, revision)
|
upgrade_database(database_path, revision)
|
||||||
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
|
|||||||
# Downgrade to base (empty database)
|
# Downgrade to base (empty database)
|
||||||
$ nextcloud-mcp-server db downgrade --revision base
|
$ nextcloud-mcp-server db downgrade --revision base
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import downgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Downgrading database to revision: {revision}")
|
click.echo(f"Downgrading database to revision: {revision}")
|
||||||
downgrade_database(database_path, revision)
|
downgrade_database(database_path, revision)
|
||||||
@@ -362,8 +365,6 @@ def current(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db current
|
$ nextcloud-mcp-server db current
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import get_current_revision
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
revision = get_current_revision(database_path)
|
revision = get_current_revision(database_path)
|
||||||
if revision:
|
if revision:
|
||||||
@@ -397,8 +398,6 @@ def history(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db history
|
$ nextcloud-mcp-server db history
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import show_migration_history
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo("Migration history:")
|
click.echo("Migration history:")
|
||||||
show_migration_history(database_path)
|
show_migration_history(database_path)
|
||||||
@@ -421,8 +420,6 @@ def migrate(message: str):
|
|||||||
|
|
||||||
Note: You must manually edit the generated migration file to add SQL statements.
|
Note: You must manually edit the generated migration file to add SQL statements.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import create_migration
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Creating new migration: {message}")
|
click.echo(f"Creating new migration: {message}")
|
||||||
create_migration(message)
|
create_migration(message)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
from httpx import (
|
from httpx import (
|
||||||
AsyncBaseTransport,
|
AsyncBaseTransport,
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncHTTPTransport,
|
|
||||||
Auth,
|
Auth,
|
||||||
BasicAuth,
|
BasicAuth,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,6 +12,7 @@ from httpx import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..controllers.notes_search import NotesSearchController
|
from ..controllers.notes_search import NotesSearchController
|
||||||
|
from ..http import nextcloud_httpx_transport
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
from .contacts import ContactsClient
|
from .contacts import ContactsClient
|
||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
|||||||
self._client = AsyncClient(
|
self._client = AsyncClient(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||||
event_hooks={"request": [log_request], "response": [log_response]},
|
event_hooks={"request": [log_request], "response": [log_response]},
|
||||||
timeout=Timeout(timeout=30, connect=5),
|
timeout=Timeout(timeout=30, connect=5),
|
||||||
)
|
)
|
||||||
@@ -113,7 +113,7 @@ class NextcloudClient:
|
|||||||
Returns:
|
Returns:
|
||||||
NextcloudClient configured with bearer token authentication
|
NextcloudClient configured with bearer token authentication
|
||||||
"""
|
"""
|
||||||
from ..auth import BearerAuth
|
from ..auth import BearerAuth # noqa: PLC0415
|
||||||
|
|
||||||
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||||
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import uuid
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from caldav.async_collection import AsyncCalendar
|
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||||
from caldav.async_davclient import AsyncDAVClient
|
from caldav.async_davclient import AsyncDAVClient
|
||||||
|
from caldav.elements import cdav, dav
|
||||||
from httpx import Auth
|
from httpx import Auth
|
||||||
from icalendar import Alarm, Calendar, vRecur
|
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||||
from icalendar import Event as ICalEvent
|
from icalendar import Event as ICalEvent
|
||||||
from icalendar import Todo as ICalTodo
|
from icalendar import Todo as ICalTodo
|
||||||
|
from lxml import etree # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
from ..config import get_nextcloud_ssl_verify
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,6 +38,7 @@ class CalendarClient:
|
|||||||
url=f"{base_url}/remote.php/dav/",
|
url=f"{base_url}/remote.php/dav/",
|
||||||
username=username,
|
username=username,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
|
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
|
||||||
)
|
)
|
||||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||||
|
|
||||||
@@ -100,8 +105,6 @@ class CalendarClient:
|
|||||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||||
from lxml import etree # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
<d:prop>
|
<d:prop>
|
||||||
@@ -255,18 +258,35 @@ class CalendarClient:
|
|||||||
"""List events in a calendar within date range."""
|
"""List events in a calendar within date range."""
|
||||||
calendar = self._get_calendar(calendar_name)
|
calendar = self._get_calendar(calendar_name)
|
||||||
|
|
||||||
# Get all events using caldav library (now with proper filter)
|
if start_datetime or end_datetime:
|
||||||
events = await calendar.events()
|
# Build CalDAV REPORT with time-range filter for server-side filtering
|
||||||
|
events = await self._search_events_by_date(
|
||||||
|
calendar, start_datetime, end_datetime
|
||||||
|
)
|
||||||
|
# Expand is only used when both bounds are provided
|
||||||
|
expanded = bool(start_datetime and end_datetime)
|
||||||
|
else:
|
||||||
|
# No date filter — fetch all events
|
||||||
|
events = await calendar.events()
|
||||||
|
expanded = False
|
||||||
|
|
||||||
result = []
|
result = []
|
||||||
for event in events:
|
for event in events:
|
||||||
await event.load(only_if_unloaded=True)
|
await event.load(only_if_unloaded=True)
|
||||||
if event.data:
|
if event.data:
|
||||||
event_dict = self._parse_ical_event(event.data)
|
if expanded:
|
||||||
if event_dict:
|
# Server-side expansion: each response resource may contain
|
||||||
event_dict["href"] = str(event.url)
|
# multiple VEVENTs (one per recurrence occurrence)
|
||||||
event_dict["etag"] = ""
|
for event_dict in self._parse_all_ical_events(event.data):
|
||||||
result.append(event_dict)
|
event_dict["href"] = str(event.url)
|
||||||
|
event_dict["etag"] = ""
|
||||||
|
result.append(event_dict)
|
||||||
|
else:
|
||||||
|
event_dict = self._parse_ical_event(event.data)
|
||||||
|
if event_dict:
|
||||||
|
event_dict["href"] = str(event.url)
|
||||||
|
event_dict["etag"] = ""
|
||||||
|
result.append(event_dict)
|
||||||
|
|
||||||
if len(result) >= limit:
|
if len(result) >= limit:
|
||||||
break
|
break
|
||||||
@@ -274,6 +294,53 @@ class CalendarClient:
|
|||||||
logger.debug(f"Found {len(result)} events")
|
logger.debug(f"Found {len(result)} events")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
async def _search_events_by_date(
|
||||||
|
self,
|
||||||
|
calendar: AsyncCalendar,
|
||||||
|
start_datetime: Optional[dt.datetime] = None,
|
||||||
|
end_datetime: Optional[dt.datetime] = None,
|
||||||
|
) -> list:
|
||||||
|
"""Execute a CalDAV REPORT with time-range filter."""
|
||||||
|
# Ensure naive datetimes are treated as UTC
|
||||||
|
if start_datetime and start_datetime.tzinfo is None:
|
||||||
|
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||||
|
if end_datetime and end_datetime.tzinfo is None:
|
||||||
|
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
|
||||||
|
|
||||||
|
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
|
||||||
|
inner_comp_filter = cdav.CompFilter(name="VEVENT")
|
||||||
|
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
|
||||||
|
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
|
||||||
|
filter_element = cdav.Filter() + outer_comp_filter
|
||||||
|
|
||||||
|
# When both bounds are provided, request server-side expansion of
|
||||||
|
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
|
||||||
|
# a separate VEVENT with its own DTSTART, with RRULE stripped.
|
||||||
|
data = cdav.CalendarData()
|
||||||
|
if start_datetime and end_datetime:
|
||||||
|
data += cdav.Expand(start_datetime, end_datetime)
|
||||||
|
|
||||||
|
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
|
||||||
|
|
||||||
|
body = etree.tostring(
|
||||||
|
query.xmlelement(), encoding="utf-8", xml_declaration=True
|
||||||
|
)
|
||||||
|
assert calendar.client is not None
|
||||||
|
response = await calendar.client.report(str(calendar.url), body, depth=1)
|
||||||
|
|
||||||
|
# Parse response (same pattern as AsyncCalendar.search)
|
||||||
|
objects = []
|
||||||
|
response_data = response.expand_simple_props([cdav.CalendarData()])
|
||||||
|
for href, props in response_data.items():
|
||||||
|
if href == str(calendar.url):
|
||||||
|
continue
|
||||||
|
cal_data = props.get(cdav.CalendarData.tag)
|
||||||
|
if cal_data:
|
||||||
|
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
|
||||||
|
objects.append(obj)
|
||||||
|
|
||||||
|
return objects
|
||||||
|
|
||||||
async def create_event(
|
async def create_event(
|
||||||
self, calendar_name: str, event_data: Dict[str, Any]
|
self, calendar_name: str, event_data: Dict[str, Any]
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
@@ -583,7 +650,7 @@ class CalendarClient:
|
|||||||
# Add categories
|
# Add categories
|
||||||
categories = event_data.get("categories", "")
|
categories = event_data.get("categories", "")
|
||||||
if categories:
|
if categories:
|
||||||
event.add("categories", categories.split(","))
|
event.add("categories", [c.strip() for c in categories.split(",")])
|
||||||
|
|
||||||
# Add priority and status
|
# Add priority and status
|
||||||
priority = event_data.get("priority", 5)
|
priority = event_data.get("priority", 5)
|
||||||
@@ -633,75 +700,92 @@ class CalendarClient:
|
|||||||
cal.add_component(event)
|
cal.add_component(event)
|
||||||
return cal.to_ical().decode("utf-8")
|
return cal.to_ical().decode("utf-8")
|
||||||
|
|
||||||
|
def _extract_vevent_data(self, component) -> Dict[str, Any]:
|
||||||
|
"""Extract event data from a single VEVENT component.
|
||||||
|
|
||||||
|
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
|
||||||
|
"""
|
||||||
|
event_data: Dict[str, Any] = {
|
||||||
|
"uid": str(component.get("uid", "")),
|
||||||
|
"title": str(component.get("summary", "")),
|
||||||
|
"description": str(component.get("description", "")),
|
||||||
|
"location": str(component.get("location", "")),
|
||||||
|
"status": str(component.get("status", "CONFIRMED")),
|
||||||
|
"priority": int(component.get("priority", 5)),
|
||||||
|
"privacy": str(component.get("class", "PUBLIC")),
|
||||||
|
"url": str(component.get("url", "")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Handle dates
|
||||||
|
dtstart = component.get("dtstart")
|
||||||
|
if dtstart:
|
||||||
|
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
||||||
|
dtstart.dt, dt.datetime
|
||||||
|
):
|
||||||
|
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||||
|
event_data["all_day"] = True
|
||||||
|
else:
|
||||||
|
event_data["start_datetime"] = dtstart.dt.isoformat()
|
||||||
|
event_data["all_day"] = False
|
||||||
|
|
||||||
|
dtend = component.get("dtend")
|
||||||
|
if dtend:
|
||||||
|
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
|
||||||
|
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||||
|
else:
|
||||||
|
event_data["end_datetime"] = dtend.dt.isoformat()
|
||||||
|
|
||||||
|
# Handle categories
|
||||||
|
categories = component.get("categories")
|
||||||
|
if categories:
|
||||||
|
event_data["categories"] = self._extract_categories(categories)
|
||||||
|
|
||||||
|
# Handle recurrence
|
||||||
|
rrule = component.get("rrule")
|
||||||
|
if rrule:
|
||||||
|
event_data["recurring"] = True
|
||||||
|
event_data["recurrence_rule"] = str(rrule)
|
||||||
|
|
||||||
|
# Handle attendees
|
||||||
|
attendees = []
|
||||||
|
for attendee in component.get("attendee", []):
|
||||||
|
if isinstance(attendee, list):
|
||||||
|
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
|
||||||
|
else:
|
||||||
|
attendees.append(str(attendee).replace("mailto:", ""))
|
||||||
|
if attendees:
|
||||||
|
event_data["attendees"] = ",".join(attendees)
|
||||||
|
|
||||||
|
return event_data
|
||||||
|
|
||||||
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Parse iCalendar text and extract event data."""
|
"""Parse iCalendar text and extract the first event."""
|
||||||
try:
|
try:
|
||||||
cal = Calendar.from_ical(ical_text)
|
cal = Calendar.from_ical(ical_text)
|
||||||
for component in cal.walk():
|
for component in cal.walk():
|
||||||
if component.name == "VEVENT":
|
if component.name == "VEVENT":
|
||||||
event_data = {
|
return self._extract_vevent_data(component)
|
||||||
"uid": str(component.get("uid", "")),
|
|
||||||
"title": str(component.get("summary", "")),
|
|
||||||
"description": str(component.get("description", "")),
|
|
||||||
"location": str(component.get("location", "")),
|
|
||||||
"status": str(component.get("status", "CONFIRMED")),
|
|
||||||
"priority": int(component.get("priority", 5)),
|
|
||||||
"privacy": str(component.get("class", "PUBLIC")),
|
|
||||||
"url": str(component.get("url", "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Handle dates
|
|
||||||
dtstart = component.get("dtstart")
|
|
||||||
if dtstart:
|
|
||||||
if isinstance(dtstart.dt, dt.date) and not isinstance(
|
|
||||||
dtstart.dt, dt.datetime
|
|
||||||
):
|
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
|
||||||
event_data["all_day"] = True
|
|
||||||
else:
|
|
||||||
event_data["start_datetime"] = dtstart.dt.isoformat()
|
|
||||||
event_data["all_day"] = False
|
|
||||||
|
|
||||||
dtend = component.get("dtend")
|
|
||||||
if dtend:
|
|
||||||
if isinstance(dtend.dt, dt.date) and not isinstance(
|
|
||||||
dtend.dt, dt.datetime
|
|
||||||
):
|
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
|
||||||
else:
|
|
||||||
event_data["end_datetime"] = dtend.dt.isoformat()
|
|
||||||
|
|
||||||
# Handle categories
|
|
||||||
categories = component.get("categories")
|
|
||||||
if categories:
|
|
||||||
event_data["categories"] = self._extract_categories(categories)
|
|
||||||
|
|
||||||
# Handle recurrence
|
|
||||||
rrule = component.get("rrule")
|
|
||||||
if rrule:
|
|
||||||
event_data["recurring"] = True
|
|
||||||
event_data["recurrence_rule"] = str(rrule)
|
|
||||||
|
|
||||||
# Handle attendees
|
|
||||||
attendees = []
|
|
||||||
for attendee in component.get("attendee", []):
|
|
||||||
if isinstance(attendee, list):
|
|
||||||
attendees.extend(
|
|
||||||
str(a).replace("mailto:", "") for a in attendee
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
attendees.append(str(attendee).replace("mailto:", ""))
|
|
||||||
if attendees:
|
|
||||||
event_data["attendees"] = ",".join(attendees)
|
|
||||||
|
|
||||||
return event_data
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error parsing iCalendar event: {e}")
|
logger.error(f"Error parsing iCalendar event: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
|
||||||
|
"""Parse iCalendar text and extract ALL event occurrences.
|
||||||
|
|
||||||
|
Used with server-side expansion where a single VCALENDAR contains
|
||||||
|
multiple VEVENT components (one per recurrence occurrence).
|
||||||
|
"""
|
||||||
|
results: list[Dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
cal = Calendar.from_ical(ical_text)
|
||||||
|
for component in cal.walk():
|
||||||
|
if component.name == "VEVENT":
|
||||||
|
results.append(self._extract_vevent_data(component))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error parsing iCalendar events: {e}")
|
||||||
|
return results
|
||||||
|
|
||||||
def _merge_ical_properties(
|
def _merge_ical_properties(
|
||||||
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
|
||||||
) -> str:
|
) -> str:
|
||||||
@@ -727,6 +811,50 @@ class CalendarClient:
|
|||||||
if "url" in event_data:
|
if "url" in event_data:
|
||||||
component["URL"] = event_data["url"]
|
component["URL"] = event_data["url"]
|
||||||
|
|
||||||
|
# Handle categories
|
||||||
|
if "categories" in event_data:
|
||||||
|
categories_str = event_data["categories"]
|
||||||
|
if categories_str:
|
||||||
|
component["CATEGORIES"] = [
|
||||||
|
c.strip() for c in categories_str.split(",")
|
||||||
|
]
|
||||||
|
elif "CATEGORIES" in component:
|
||||||
|
del component["CATEGORIES"]
|
||||||
|
|
||||||
|
# Handle recurrence rule
|
||||||
|
if "recurrence_rule" in event_data:
|
||||||
|
rrule_str = event_data["recurrence_rule"]
|
||||||
|
if rrule_str:
|
||||||
|
component["RRULE"] = vRecur.from_ical(rrule_str)
|
||||||
|
elif "RRULE" in component:
|
||||||
|
del component["RRULE"]
|
||||||
|
|
||||||
|
# Handle attendees
|
||||||
|
if "attendees" in event_data:
|
||||||
|
attendees_str = event_data["attendees"]
|
||||||
|
# Remove all existing attendees first
|
||||||
|
while "ATTENDEE" in component:
|
||||||
|
del component["ATTENDEE"]
|
||||||
|
if attendees_str:
|
||||||
|
for email in attendees_str.split(","):
|
||||||
|
if email.strip():
|
||||||
|
component.add("attendee", f"mailto:{email.strip()}")
|
||||||
|
|
||||||
|
# Handle reminder (VALARM)
|
||||||
|
if "reminder_minutes" in event_data:
|
||||||
|
component.subcomponents = [
|
||||||
|
sub
|
||||||
|
for sub in component.subcomponents
|
||||||
|
if sub.name != "VALARM"
|
||||||
|
]
|
||||||
|
minutes = event_data["reminder_minutes"]
|
||||||
|
if minutes > 0:
|
||||||
|
alarm = Alarm()
|
||||||
|
alarm.add("action", "DISPLAY")
|
||||||
|
alarm.add("description", "Event reminder")
|
||||||
|
alarm.add("trigger", dt.timedelta(minutes=-minutes))
|
||||||
|
component.add_component(alarm)
|
||||||
|
|
||||||
# Handle dates
|
# Handle dates
|
||||||
if "start_datetime" in event_data:
|
if "start_datetime" in event_data:
|
||||||
start_str = event_data["start_datetime"]
|
start_str = event_data["start_datetime"]
|
||||||
@@ -757,8 +885,6 @@ class CalendarClient:
|
|||||||
component["DTEND"] = end_dt
|
component["DTEND"] = end_dt
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
now = dt.datetime.now(dt.UTC)
|
now = dt.datetime.now(dt.UTC)
|
||||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||||
component["DTSTAMP"] = vDDDTypes(now)
|
component["DTSTAMP"] = vDDDTypes(now)
|
||||||
@@ -823,24 +949,18 @@ class CalendarClient:
|
|||||||
# Due date
|
# Due date
|
||||||
due = todo_data.get("due", "")
|
due = todo_data.get("due", "")
|
||||||
if due:
|
if due:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
due_dt = self._ensure_timezone_aware(due)
|
due_dt = self._ensure_timezone_aware(due)
|
||||||
todo.add("due", vDDDTypes(due_dt))
|
todo.add("due", vDDDTypes(due_dt))
|
||||||
|
|
||||||
# Start date
|
# Start date
|
||||||
dtstart = todo_data.get("dtstart", "")
|
dtstart = todo_data.get("dtstart", "")
|
||||||
if dtstart:
|
if dtstart:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
start_dt = self._ensure_timezone_aware(dtstart)
|
start_dt = self._ensure_timezone_aware(dtstart)
|
||||||
todo.add("dtstart", vDDDTypes(start_dt))
|
todo.add("dtstart", vDDDTypes(start_dt))
|
||||||
|
|
||||||
# Completed timestamp
|
# Completed timestamp
|
||||||
completed = todo_data.get("completed", "")
|
completed = todo_data.get("completed", "")
|
||||||
if completed:
|
if completed:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
completed_dt = self._ensure_timezone_aware(completed)
|
completed_dt = self._ensure_timezone_aware(completed)
|
||||||
todo.add("completed", vDDDTypes(completed_dt))
|
todo.add("completed", vDDDTypes(completed_dt))
|
||||||
|
|
||||||
@@ -929,9 +1049,6 @@ class CalendarClient:
|
|||||||
component["PERCENT-COMPLETE"] = percent_value
|
component["PERCENT-COMPLETE"] = percent_value
|
||||||
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
||||||
|
|
||||||
# Import vDDDTypes at the beginning for datetime formatting
|
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
# Handle due date
|
# Handle due date
|
||||||
if "due" in todo_data:
|
if "due" in todo_data:
|
||||||
due_str = todo_data["due"]
|
due_str = todo_data["due"]
|
||||||
@@ -960,7 +1077,9 @@ class CalendarClient:
|
|||||||
if "categories" in todo_data:
|
if "categories" in todo_data:
|
||||||
categories_str = todo_data["categories"]
|
categories_str = todo_data["categories"]
|
||||||
if categories_str:
|
if categories_str:
|
||||||
component["CATEGORIES"] = categories_str.split(",")
|
component["CATEGORIES"] = [
|
||||||
|
c.strip() for c in categories_str.split(",")
|
||||||
|
]
|
||||||
logger.debug(f"Set CATEGORIES to {categories_str}")
|
logger.debug(f"Set CATEGORIES to {categories_str}")
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from httpx import Timeout
|
from httpx import Timeout
|
||||||
|
|
||||||
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of matching recipe stubs
|
List of matching recipe stubs
|
||||||
"""
|
"""
|
||||||
# URL encode the query
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_query = quote(query)
|
encoded_query = quote(query)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||||
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs in the category
|
List of recipe stubs in the category
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_category = quote(category)
|
encoded_category = quote(category)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||||
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
New category name
|
New category name
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_old_name = quote(old_name)
|
encoded_old_name = quote(old_name)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs matching the keywords
|
List of recipe stubs matching the keywords
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
# Join keywords with commas
|
# Join keywords with commas
|
||||||
keywords_str = ",".join(keywords)
|
keywords_str = ",".join(keywords)
|
||||||
encoded_keywords = quote(keywords_str)
|
encoded_keywords = quote(keywords_str)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import Any, AsyncIterator, Dict, Optional
|
from typing import Any, AsyncIterator, Dict, Optional
|
||||||
|
|
||||||
from .base import BaseNextcloudClient
|
from .base import BaseNextcloudClient
|
||||||
|
from .webdav import WebDAVClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -157,9 +158,6 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Import here to avoid circular imports
|
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
await webdav_client.cleanup_old_attachment_directory(
|
await webdav_client.cleanup_old_attachment_directory(
|
||||||
note_id=note_id, old_category=old_note.get("category", "")
|
note_id=note_id, old_category=old_note.get("category", "")
|
||||||
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
# Clean up attachment directories
|
# Clean up attachment directories
|
||||||
try:
|
try:
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
|
|
||||||
for cat in potential_categories:
|
for cat in potential_categories:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Decode href path and extract the file path
|
# Decode href path and extract the file path
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
href_path = unquote(href_elem.text)
|
href_path = unquote(href_elem.text)
|
||||||
# Remove WebDAV prefix to get user-relative path
|
# Remove WebDAV prefix to get user-relative path
|
||||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||||
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
# Parse last modified timestamp
|
# Parse last modified timestamp
|
||||||
last_modified_timestamp = None
|
last_modified_timestamp = None
|
||||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||||
last_modified_timestamp = int(dt.timestamp())
|
last_modified_timestamp = int(dt.timestamp())
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -181,6 +182,10 @@ class Settings:
|
|||||||
nextcloud_username: Optional[str] = None
|
nextcloud_username: Optional[str] = None
|
||||||
nextcloud_password: Optional[str] = None
|
nextcloud_password: Optional[str] = None
|
||||||
|
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl: bool = True
|
||||||
|
nextcloud_ca_bundle: Optional[str] = None
|
||||||
|
|
||||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||||
@@ -252,9 +257,23 @@ class Settings:
|
|||||||
log_include_trace_context: bool = True
|
log_include_trace_context: bool = True
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Validate Qdrant configuration and set defaults."""
|
"""Validate configuration and set defaults."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Validate SSL/TLS configuration
|
||||||
|
if not self.nextcloud_verify_ssl:
|
||||||
|
logger.warning(
|
||||||
|
"NEXTCLOUD_VERIFY_SSL is disabled. "
|
||||||
|
"TLS certificate verification is turned off for all Nextcloud connections. "
|
||||||
|
"This is insecure and should only be used for development/testing."
|
||||||
|
)
|
||||||
|
if self.nextcloud_ca_bundle:
|
||||||
|
if not os.path.isfile(self.nextcloud_ca_bundle):
|
||||||
|
raise ValueError(
|
||||||
|
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||||
|
)
|
||||||
|
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
|
||||||
|
|
||||||
# Ensure mutual exclusivity
|
# Ensure mutual exclusivity
|
||||||
if self.qdrant_url and self.qdrant_location:
|
if self.qdrant_url and self.qdrant_location:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -504,6 +523,11 @@ def get_settings() -> Settings:
|
|||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl=(
|
||||||
|
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
),
|
||||||
|
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||||
# ADR-005: Token Audience Validation
|
# ADR-005: Token Audience Validation
|
||||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||||
@@ -569,3 +593,20 @@ def get_settings() -> Settings:
|
|||||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||||
== "true",
|
== "true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
|
||||||
|
"""Return the SSL verification setting for Nextcloud connections.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
|
||||||
|
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
|
||||||
|
- True otherwise (default system CA verification)
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.nextcloud_verify_ssl:
|
||||||
|
return False
|
||||||
|
if settings.nextcloud_ca_bundle:
|
||||||
|
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
|
||||||
|
return ctx
|
||||||
|
return True
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import logging
|
|||||||
from httpx import BasicAuth
|
from httpx import BasicAuth
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.context_helper import (
|
||||||
|
get_client_from_context,
|
||||||
|
get_session_client_from_context,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
@@ -80,11 +84,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
|
|
||||||
# OAuth mode (has 'nextcloud_host' attribute)
|
# OAuth mode (has 'nextcloud_host' attribute)
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||||
from nextcloud_mcp_server.auth.context_helper import (
|
|
||||||
get_client_from_context,
|
|
||||||
get_session_client_from_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.enable_token_exchange:
|
if settings.enable_token_exchange:
|
||||||
# Mode 2: Exchange MCP token for Nextcloud token
|
# Mode 2: Exchange MCP token for Nextcloud token
|
||||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||||
@@ -131,7 +130,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
ValueError: If required session config fields are missing
|
ValueError: If required session config fields are missing
|
||||||
"""
|
"""
|
||||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
|
||||||
|
|
||||||
session_config = get_smithery_session_config()
|
session_config = get_smithery_session_config()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
from alembic.config import Config
|
||||||
|
|
||||||
|
import nextcloud_mcp_server.alembic as alembic_package
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Alembic Config object configured for the specified database
|
Alembic Config object configured for the specified database
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server import alembic as alembic_package
|
|
||||||
|
|
||||||
# Use package location (works in both editable and installed modes)
|
# Use package location (works in both editable and installed modes)
|
||||||
if alembic_package.__file__ is None:
|
if alembic_package.__file__ is None:
|
||||||
raise RuntimeError("alembic package __file__ is None")
|
raise RuntimeError("alembic package __file__ is None")
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from prometheus_client import (
|
|||||||
start_http_server,
|
start_http_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -426,8 +428,6 @@ def instrument_tool(func):
|
|||||||
Wrapped function with metrics and tracing instrumentation
|
Wrapped function with metrics and tracing instrumentation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
tool_name = func.__name__
|
tool_name = func.__name__
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pymupdf4llm
|
import pymupdf4llm
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +38,6 @@ async def _get_chunk_from_qdrant(
|
|||||||
Full chunk text from Qdrant excerpt field, or None if not found
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -104,11 +103,6 @@ async def _get_chunk_by_index_from_qdrant(
|
|||||||
Full chunk text from Qdrant excerpt field, or None if not found
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -165,11 +159,6 @@ async def _get_file_path_from_qdrant(
|
|||||||
File path string, or None if not found in Qdrant
|
File path string, or None if not found in Qdrant
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -225,11 +214,6 @@ async def _get_deck_metadata_from_qdrant(
|
|||||||
Dictionary with board_id and stack_id, or None if not found
|
Dictionary with board_id and stack_id, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -355,8 +339,6 @@ async def get_chunk_with_context(
|
|||||||
|
|
||||||
# Fetch adjacent chunks for context expansion
|
# Fetch adjacent chunks for context expansion
|
||||||
# Get chunk overlap from config to remove duplicate text
|
# Get chunk overlap from config to remove duplicate text
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
chunk_overlap = settings.document_chunk_overlap
|
chunk_overlap = settings.document_chunk_overlap
|
||||||
|
|
||||||
@@ -587,8 +569,6 @@ async def _fetch_document_text(
|
|||||||
return None
|
return None
|
||||||
elif doc_type == "news_item":
|
elif doc_type == "news_item":
|
||||||
# Fetch news item by ID
|
# Fetch news item by ID
|
||||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
|
||||||
|
|
||||||
item = await nc_client.news.get_item(int(doc_id))
|
item = await nc_client.news.get_item(int(doc_id))
|
||||||
# Reconstruct full content as indexed: title + source + URL + body
|
# Reconstruct full content as indexed: title + source + URL + body
|
||||||
# This ensures chunk offsets align with indexed content structure
|
# This ensures chunk offsets align with indexed content structure
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pymupdf4llm
|
import pymupdf4llm
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -682,8 +685,6 @@ class PDFHighlighter:
|
|||||||
# Clean up temp directory and PDF file
|
# Clean up temp directory and PDF file
|
||||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||||
try:
|
try:
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(temp_pdf_path.parent)
|
shutil.rmtree(temp_pdf_path.parent)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -720,11 +721,6 @@ class PDFHighlighter:
|
|||||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||||
Chunks that fail to highlight are omitted from the result.
|
Chunks that fail to highlight are omitted from the result.
|
||||||
"""
|
"""
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
results: dict[int, tuple[bytes, int, int]] = {}
|
results: dict[int, tuple[bytes, int, int]] = {}
|
||||||
|
|
||||||
if not chunks:
|
if not chunks:
|
||||||
@@ -798,9 +794,6 @@ class PDFHighlighter:
|
|||||||
|
|
||||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||||
# This avoids expensive page.get_pixmap() calls per chunk
|
# This avoids expensive page.get_pixmap() calls per chunk
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
# PIL color for bounding box (RGB tuple)
|
# PIL color for bounding box (RGB tuple)
|
||||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
import jwt
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
@@ -20,9 +20,13 @@ from mcp.types import ToolAnnotations
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -69,7 +73,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
|||||||
"OIDC_DISCOVERY_URI",
|
"OIDC_DISCOVERY_URI",
|
||||||
"http://localhost:8080/.well-known/openid-configuration",
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||||
discovery_response.raise_for_status()
|
discovery_response.raise_for_status()
|
||||||
discovery = discovery_response.json()
|
discovery = discovery_response.json()
|
||||||
@@ -156,11 +160,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
Returns:
|
Returns:
|
||||||
ProvisioningStatus with current provisioning state
|
ProvisioningStatus with current provisioning state
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Check for app password first (interim solution)
|
# Check for app password first (interim solution)
|
||||||
@@ -304,8 +303,6 @@ async def provision_nextcloud_access(
|
|||||||
|
|
||||||
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
||||||
# and ENABLE_OFFLINE_ACCESS environment variables)
|
# and ENABLE_OFFLINE_ACCESS environment variables)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.enable_offline_access:
|
if not settings.enable_offline_access:
|
||||||
return ProvisioningResult(
|
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
|
# Not logged in - generate OAuth URL for Flow 2
|
||||||
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.enable_offline_access:
|
if not settings.enable_offline_access:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from httpx import RequestError
|
|||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
|
ClientCapabilities,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
ModelHint,
|
ModelHint,
|
||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
|
SamplingCapability,
|
||||||
SamplingMessage,
|
SamplingMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
ToolAnnotations,
|
ToolAnnotations,
|
||||||
)
|
)
|
||||||
|
from qdrant_client.models import Filter
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.semantic import (
|
from nextcloud_mcp_server.models.semantic import (
|
||||||
SamplingSearchResponse,
|
SamplingSearchResponse,
|
||||||
@@ -28,6 +32,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -82,8 +88,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
username = client.username
|
username = client.username
|
||||||
@@ -373,8 +377,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Check if client supports sampling
|
# 3. Check if client supports sampling
|
||||||
from mcp.types import ClientCapabilities, SamplingCapability
|
|
||||||
|
|
||||||
client_has_sampling = ctx.session.check_client_capability(
|
client_has_sampling = ctx.session.check_client_capability(
|
||||||
ClientCapabilities(sampling=SamplingCapability())
|
ClientCapabilities(sampling=SamplingCapability())
|
||||||
)
|
)
|
||||||
@@ -658,8 +660,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Check if vector sync is enabled (supports both old and new env var names)
|
# 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()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return VectorSyncStatusResponse(
|
return VectorSyncStatusResponse(
|
||||||
@@ -694,15 +694,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import Filter
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.placeholder import (
|
|
||||||
get_placeholder_filter,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection, excluding placeholders
|
# Count documents in collection, excluding placeholders
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def main():
|
|||||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||||
|
|
||||||
# Import app after setting environment variables
|
# Import app after setting environment variables
|
||||||
from nextcloud_mcp_server.app import get_app
|
from nextcloud_mcp_server.app import get_app # noqa: PLC0415
|
||||||
|
|
||||||
# Create the app with streamable-http transport (required for Smithery)
|
# Create the app with streamable-http transport (required for Smithery)
|
||||||
app = get_app(transport="streamable-http")
|
app = get_app(transport="streamable-http")
|
||||||
|
|||||||
@@ -31,14 +31,15 @@ from anyio.streams.memory import (
|
|||||||
MemoryObjectReceiveStream,
|
MemoryObjectReceiveStream,
|
||||||
MemoryObjectSendStream,
|
MemoryObjectSendStream,
|
||||||
)
|
)
|
||||||
from httpx import BasicAuth
|
from httpx import BasicAuth, HTTPStatusError
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.processor import process_document
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -89,8 +90,6 @@ async def get_user_client_basic_auth(
|
|||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned an app password
|
NotProvisionedError: If user has not provisioned an app password
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
# Get or create storage instance
|
# Get or create storage instance
|
||||||
if storage is None:
|
if storage is None:
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
@@ -210,9 +209,41 @@ async def user_scanner_task(
|
|||||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
max_consecutive_errors = 5
|
||||||
|
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
|
# Pre-validate credentials before entering scan loop
|
||||||
|
try:
|
||||||
|
nc_client = await get_user_client(
|
||||||
|
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await nc_client.capabilities() # Lightweight OCS call to validate creds
|
||||||
|
logger.info(f"[{mode_label}] Credentials validated for {user_id}")
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Credential validation failed for {user_id} "
|
||||||
|
f"(HTTP {e.response.status_code}), not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await nc_client.close()
|
||||||
|
except NotProvisionedError:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] User {user_id} not provisioned, not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Pre-validation failed for {user_id}: {e}. "
|
||||||
|
f"Proceeding to scan loop (has its own error handling)."
|
||||||
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
nc_client = None
|
nc_client = None
|
||||||
try:
|
try:
|
||||||
@@ -228,21 +259,64 @@ async def user_scanner_task(
|
|||||||
nc_client=nc_client,
|
nc_client=nc_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0 # Reset on success
|
||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
status_code = e.response.status_code
|
||||||
|
if status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner auth failed for {user_id} "
|
||||||
|
f"(HTTP {status_code}), stopping scanner. "
|
||||||
|
f"User may need to re-provision credentials."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
elif status_code == 429:
|
||||||
|
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner rate-limited for {user_id}, "
|
||||||
|
f"backing off {retry_after}s"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(retry_after):
|
||||||
|
await shutdown_event.wait()
|
||||||
|
# anyio.get_cancelled_exc_class() catches task cancellation
|
||||||
|
# (e.g. from task group teardown) so we exit cleanly.
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
consecutive_errors += 1
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner HTTP error for {user_id}: {e} "
|
||||||
|
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
consecutive_errors += 1
|
||||||
logger.error(
|
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:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
await nc_client.close()
|
await nc_client.close()
|
||||||
|
|
||||||
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} "
|
||||||
|
f"consecutive errors, stopping scanner"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
# Sleep until next interval or wake event
|
# Sleep until next interval or wake event
|
||||||
try:
|
try:
|
||||||
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||||
@@ -276,8 +350,6 @@ async def multi_user_processor_task(
|
|||||||
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.vector.processor import process_document
|
|
||||||
|
|
||||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from qdrant_client import models
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
@@ -82,8 +83,6 @@ async def write_placeholder_point(
|
|||||||
|
|
||||||
# Create empty sparse vector for placeholders
|
# Create empty sparse vector for placeholders
|
||||||
# Use models.SparseVector with empty indices/values
|
# Use models.SparseVector with empty indices/values
|
||||||
from qdrant_client import models
|
|
||||||
|
|
||||||
empty_sparse = models.SparseVector(indices=[], values=[])
|
empty_sparse = models.SparseVector(indices=[], values=[])
|
||||||
|
|
||||||
# Generate deterministic point ID
|
# Generate deterministic point ID
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
|||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||||
from nextcloud_mcp_server.observability.metrics import (
|
from nextcloud_mcp_server.observability.metrics import (
|
||||||
record_qdrant_operation,
|
record_qdrant_operation,
|
||||||
@@ -24,7 +25,9 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
update_vector_sync_queue_size,
|
update_vector_sync_queue_size,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||||
@@ -275,8 +278,6 @@ async def _index_document(
|
|||||||
content_bytes = None # Notes don't have binary content
|
content_bytes = None # Notes don't have binary content
|
||||||
content_type = None
|
content_type = None
|
||||||
elif doc_task.doc_type == "news_item":
|
elif doc_task.doc_type == "news_item":
|
||||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
|
||||||
|
|
||||||
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||||
# Convert HTML body to Markdown for better embedding
|
# Convert HTML body to Markdown for better embedding
|
||||||
body_markdown = html_to_markdown(item.get("body", ""))
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
@@ -437,8 +438,6 @@ async def _index_document(
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
# Use document processor registry to extract text
|
# Use document processor registry to extract text
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -586,8 +585,6 @@ async def _index_document(
|
|||||||
"vector_sync.pdf_size": len(content_bytes),
|
"vector_sync.pdf_size": len(content_bytes),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
|
||||||
|
|
||||||
# Build chunk data for batch processing
|
# Build chunk data for batch processing
|
||||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from qdrant_client import AsyncQdrantClient, models
|
|||||||
from qdrant_client.models import Distance, VectorParams
|
from qdrant_client.models import Distance, VectorParams
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,9 +63,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
|||||||
# Get collection name (auto-generated from deployment ID + model)
|
# Get collection name (auto-generated from deployment ID + model)
|
||||||
collection_name = settings.get_collection_name()
|
collection_name = settings.get_collection_name()
|
||||||
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
|
|
||||||
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
@@ -15,6 +16,7 @@ from anyio.streams.memory import MemoryObjectSendStream
|
|||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
@@ -418,8 +420,6 @@ async def scan_user_documents(
|
|||||||
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
||||||
if isinstance(file_info.get("last_modified"), str):
|
if isinstance(file_info.get("last_modified"), str):
|
||||||
# Parse RFC 2822 date format if needed
|
# Parse RFC 2822 date format if needed
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(file_info["last_modified"])
|
dt = parsedate_to_datetime(file_info["last_modified"])
|
||||||
modified_at = int(dt.timestamp())
|
modified_at = int(dt.timestamp())
|
||||||
@@ -615,8 +615,6 @@ async def scan_news_items(
|
|||||||
Returns:
|
Returns:
|
||||||
Number of items queued for processing
|
Number of items queued for processing
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.client.news import NewsItemType
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
|
|||||||
+10
-8
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.62.0"
|
version = "0.64.2"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.23,<1.24)",
|
"mcp[cli] (>=1.26,<1.27)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=6.0.0,<7.0.0)",
|
||||||
@@ -98,23 +98,25 @@ version_files = [
|
|||||||
# Ignore tags from other components
|
# Ignore tags from other components
|
||||||
ignored_tag_formats = [
|
ignored_tag_formats = [
|
||||||
"nextcloud-mcp-server-*", # Helm chart tags
|
"nextcloud-mcp-server-*", # Helm chart tags
|
||||||
"astrolabe-v*", # Astrolabe tags
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Filter commits by scope (all scopes except helm and astrolabe)
|
# Filter commits by scope (all scopes except helm)
|
||||||
[tool.commitizen.customize]
|
[tool.commitizen.customize]
|
||||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
|
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:"
|
||||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
extend-select = ["I"]
|
extend-select = ["I", "PLC0415"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["PLC0415"]
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
||||||
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
requires = ["uv_build>=0.10.0,<0.11.0"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.uv.build-backend]
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Bump Astrolabe app version
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# Parse optional --increment flag
|
|
||||||
INCREMENT=""
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--increment)
|
|
||||||
INCREMENT="$2"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
echo "❌ Error: Unknown option: $1" >&2
|
|
||||||
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
# Validate dependencies
|
|
||||||
command -v uv >/dev/null 2>&1 || {
|
|
||||||
echo "❌ Error: uv not found" >&2
|
|
||||||
echo " Install from https://docs.astral.sh/uv/" >&2
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Validate Astrolabe directory exists
|
|
||||||
if [ ! -d "third_party/astrolabe" ]; then
|
|
||||||
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
cd third_party/astrolabe
|
|
||||||
|
|
||||||
# Validate required files exist
|
|
||||||
if [ ! -f "appinfo/info.xml" ]; then
|
|
||||||
echo "❌ Error: appinfo/info.xml not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "package.json" ]; then
|
|
||||||
echo "❌ Error: package.json not found" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Bumping Astrolabe version..."
|
|
||||||
if [ -n "$INCREMENT" ]; then
|
|
||||||
echo " Forcing $INCREMENT bump"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build commitizen command
|
|
||||||
CZ_CMD="uv run cz --config .cz.toml bump --yes"
|
|
||||||
if [ -n "$INCREMENT" ]; then
|
|
||||||
CZ_CMD="$CZ_CMD --increment $INCREMENT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Run commitizen bump and capture output
|
|
||||||
if ! output=$($CZ_CMD 2>&1); then
|
|
||||||
cd ../..
|
|
||||||
|
|
||||||
# Check if this is the expected "no commits to bump" case
|
|
||||||
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
|
||||||
echo "ℹ️ No commits eligible for version bump" >&2
|
|
||||||
echo "$output" >&2
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Otherwise, this is an actual error
|
|
||||||
echo "❌ Error: Version bump failed" >&2
|
|
||||||
echo "$output" >&2
|
|
||||||
echo "" >&2
|
|
||||||
echo "Common causes:" >&2
|
|
||||||
echo " - No commits with scope 'astrolabe' since last version" >&2
|
|
||||||
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
|
|
||||||
echo " - Git working directory not clean" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$output"
|
|
||||||
echo ""
|
|
||||||
echo "✓ Astrolabe version bumped successfully"
|
|
||||||
echo " Updated: appinfo/info.xml, package.json"
|
|
||||||
echo " Tag format: astrolabe-v\${version}"
|
|
||||||
echo ""
|
|
||||||
echo "Next steps:"
|
|
||||||
echo " cd ../.."
|
|
||||||
echo " git push --follow-tags"
|
|
||||||
|
|
||||||
cd ../..
|
|
||||||
@@ -273,6 +273,86 @@ async def test_update_event(nc_client: NextcloudClient, temporary_event: dict):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_update_event_extended_fields(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test updating categories, recurrence_rule, attendees, and reminder_minutes."""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
|
||||||
|
tomorrow = datetime.now() + timedelta(days=1)
|
||||||
|
event_data = {
|
||||||
|
"title": "Extended Fields Update Test",
|
||||||
|
"start_datetime": tomorrow.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": tomorrow.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Base event for extended-field update test",
|
||||||
|
}
|
||||||
|
|
||||||
|
event_uid = None
|
||||||
|
try:
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created base event for extended fields test: {event_uid}")
|
||||||
|
|
||||||
|
# --- Phase 1: Set all four extended fields ---
|
||||||
|
updated_data = {
|
||||||
|
"categories": "work,meeting",
|
||||||
|
"recurrence_rule": "FREQ=WEEKLY;COUNT=4",
|
||||||
|
"attendees": "alice@example.com,bob@example.com",
|
||||||
|
"reminder_minutes": 15,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, updated_data)
|
||||||
|
|
||||||
|
retrieved, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories
|
||||||
|
assert "work" in retrieved.get("categories", "")
|
||||||
|
assert "meeting" in retrieved.get("categories", "")
|
||||||
|
|
||||||
|
# Verify recurrence rule
|
||||||
|
assert retrieved.get("recurring") is True
|
||||||
|
assert "WEEKLY" in retrieved.get("recurrence_rule", "")
|
||||||
|
|
||||||
|
# Verify attendees
|
||||||
|
attendees = retrieved.get("attendees", "")
|
||||||
|
assert "alice@example.com" in attendees
|
||||||
|
assert "bob@example.com" in attendees
|
||||||
|
|
||||||
|
logger.info("Phase 1 passed: all extended fields set correctly")
|
||||||
|
|
||||||
|
# --- Phase 2: Clear all four extended fields ---
|
||||||
|
cleared_data = {
|
||||||
|
"categories": "",
|
||||||
|
"recurrence_rule": "",
|
||||||
|
"attendees": "",
|
||||||
|
"reminder_minutes": 0,
|
||||||
|
}
|
||||||
|
await nc_client.calendar.update_event(calendar_name, event_uid, cleared_data)
|
||||||
|
|
||||||
|
cleared, _ = await nc_client.calendar.get_event(calendar_name, event_uid)
|
||||||
|
|
||||||
|
# Verify categories cleared
|
||||||
|
assert not cleared.get("categories")
|
||||||
|
|
||||||
|
# Verify recurrence cleared
|
||||||
|
assert cleared.get("recurring") is not True
|
||||||
|
assert not cleared.get("recurrence_rule")
|
||||||
|
|
||||||
|
# Verify attendees cleared
|
||||||
|
assert not cleared.get("attendees")
|
||||||
|
|
||||||
|
logger.info("Phase 2 passed: all extended fields cleared correctly")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Extended fields update test failed: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def test_create_event_with_attendees(
|
async def test_create_event_with_attendees(
|
||||||
nc_client: NextcloudClient, temporary_calendar: str
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
):
|
):
|
||||||
@@ -380,6 +460,177 @@ async def test_event_with_url_and_categories(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def test_list_events_date_range_filtering(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that date range filtering actually excludes events outside the range.
|
||||||
|
|
||||||
|
Reproduces GH-538: get_calendar_events() accepted date range parameters
|
||||||
|
but returned events from the entire calendar history, ignoring date filters.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
past_uid = None
|
||||||
|
future_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create Event A: 30 days in the past
|
||||||
|
past_date = datetime.now() - timedelta(days=30)
|
||||||
|
past_event_data = {
|
||||||
|
"title": f"Past Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": past_date.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"end_datetime": past_date.strftime("%Y-%m-%dT11:00:00"),
|
||||||
|
"description": "Event in the past for date range test",
|
||||||
|
}
|
||||||
|
result_past = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, past_event_data
|
||||||
|
)
|
||||||
|
past_uid = result_past["uid"]
|
||||||
|
logger.info(f"Created past event: {past_uid}")
|
||||||
|
|
||||||
|
# Create Event B: 1 day in the future
|
||||||
|
future_date = datetime.now() + timedelta(days=1)
|
||||||
|
future_event_data = {
|
||||||
|
"title": f"Future Event {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": future_date.strftime("%Y-%m-%dT14:00:00"),
|
||||||
|
"end_datetime": future_date.strftime("%Y-%m-%dT15:00:00"),
|
||||||
|
"description": "Event in the future for date range test",
|
||||||
|
}
|
||||||
|
result_future = await nc_client.calendar.create_event(
|
||||||
|
calendar_name, future_event_data
|
||||||
|
)
|
||||||
|
future_uid = result_future["uid"]
|
||||||
|
logger.info(f"Created future event: {future_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 7 days ahead
|
||||||
|
now = datetime.now()
|
||||||
|
week_ahead = now + timedelta(days=7)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=now,
|
||||||
|
end_datetime=week_ahead,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
event_uids = [e["uid"] for e in events]
|
||||||
|
|
||||||
|
# Future event (tomorrow) SHOULD be in results
|
||||||
|
assert future_uid in event_uids, (
|
||||||
|
f"Future event {future_uid} should be in date-filtered results"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Past event (30 days ago) should NOT be in results
|
||||||
|
assert past_uid not in event_uids, (
|
||||||
|
f"Past event {past_uid} should be excluded by date range filter "
|
||||||
|
f"(GH-538: date range was being ignored)"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Date range filtering works: {len(events)} events returned, "
|
||||||
|
f"past event correctly excluded"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup both events
|
||||||
|
for uid in [past_uid, future_uid]:
|
||||||
|
if uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, uid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup failed for event {uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_recurring_event_date_range_expansion(
|
||||||
|
nc_client: NextcloudClient, temporary_calendar: str
|
||||||
|
):
|
||||||
|
"""Test that recurring events are expanded into individual occurrences.
|
||||||
|
|
||||||
|
When querying with a date range, a recurring event should return one
|
||||||
|
event dict per occurrence within the range, each with the correct
|
||||||
|
start_datetime for that occurrence (not the original master event date).
|
||||||
|
|
||||||
|
This is a follow-up to GH-538: the time-range filter correctly selected
|
||||||
|
recurring events, but returned the master event with its original DTSTART
|
||||||
|
instead of expanding occurrences.
|
||||||
|
"""
|
||||||
|
calendar_name = temporary_calendar
|
||||||
|
event_uid = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a daily recurring event starting 7 days ago
|
||||||
|
start = datetime.now() - timedelta(days=7)
|
||||||
|
event_data = {
|
||||||
|
"title": f"Daily Recurrence {uuid.uuid4().hex[:8]}",
|
||||||
|
"start_datetime": start.strftime("%Y-%m-%dT09:00:00"),
|
||||||
|
"end_datetime": start.strftime("%Y-%m-%dT10:00:00"),
|
||||||
|
"description": "Daily recurring event for expansion test",
|
||||||
|
"recurring": True,
|
||||||
|
"recurrence_rule": "FREQ=DAILY",
|
||||||
|
}
|
||||||
|
result = await nc_client.calendar.create_event(calendar_name, event_data)
|
||||||
|
event_uid = result["uid"]
|
||||||
|
logger.info(f"Created daily recurring event: {event_uid}")
|
||||||
|
|
||||||
|
# Query with date range: today → 3 days ahead
|
||||||
|
query_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
query_end = query_start + timedelta(days=3)
|
||||||
|
|
||||||
|
events = await nc_client.calendar.get_calendar_events(
|
||||||
|
calendar_name=calendar_name,
|
||||||
|
start_datetime=query_start,
|
||||||
|
end_datetime=query_end,
|
||||||
|
limit=50,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Filter to only our recurring event (calendar may have others)
|
||||||
|
our_events = [e for e in events if e["uid"] == event_uid]
|
||||||
|
|
||||||
|
# Should have multiple occurrences (one per day in the range)
|
||||||
|
assert len(our_events) >= 2, (
|
||||||
|
f"Expected multiple expanded occurrences, got {len(our_events)}. "
|
||||||
|
f"Expansion may not be working."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Each occurrence should have a different start_datetime
|
||||||
|
start_dates = [e["start_datetime"] for e in our_events]
|
||||||
|
assert len(set(start_dates)) == len(our_events), (
|
||||||
|
f"Each occurrence should have a unique start_datetime, got: {start_dates}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# No start_datetime should fall outside the queried range
|
||||||
|
for e in our_events:
|
||||||
|
event_start = datetime.fromisoformat(e["start_datetime"])
|
||||||
|
# Remove timezone info for comparison if present
|
||||||
|
if event_start.tzinfo is not None:
|
||||||
|
event_start = event_start.replace(tzinfo=None)
|
||||||
|
assert event_start >= query_start - timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is before query start {query_start}"
|
||||||
|
)
|
||||||
|
assert event_start < query_end + timedelta(hours=1), (
|
||||||
|
f"Occurrence {e['start_datetime']} is after query end {query_end}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Expanded occurrences should NOT have recurrence rules
|
||||||
|
# (server strips RRULE when expanding)
|
||||||
|
for e in our_events:
|
||||||
|
assert not e.get("recurring"), (
|
||||||
|
"Expanded occurrence should not have recurring=True, "
|
||||||
|
"RRULE should be stripped by server-side expansion"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Recurring event expansion works: {len(our_events)} occurrences "
|
||||||
|
f"returned with unique start dates"
|
||||||
|
)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
if event_uid:
|
||||||
|
try:
|
||||||
|
await nc_client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Cleanup failed for recurring event {event_uid}: {e}")
|
||||||
|
|
||||||
|
|
||||||
async def test_calendar_operations_error_handling(
|
async def test_calendar_operations_error_handling(
|
||||||
nc_client: NextcloudClient,
|
nc_client: NextcloudClient,
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -2400,6 +2400,32 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error deleting test user {username}: {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(
|
async def _get_oauth_token_for_user(
|
||||||
browser,
|
browser,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Integration test for multi-user Astrolabe background sync enablement.
|
"""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:
|
This test verifies that multiple users can independently:
|
||||||
1. Log in to Nextcloud
|
1. Log in to Nextcloud
|
||||||
2. Generate an app password in Security settings
|
2. Generate an app password in Security settings
|
||||||
@@ -829,6 +834,70 @@ async def verify_app_password_created(username: str) -> bool:
|
|||||||
return False
|
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.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.oauth
|
||||||
async def test_multi_user_astrolabe_background_sync_enablement(
|
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
|
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
|
||||||
in multi-user BasicAuth deployments.
|
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
|
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||||
await configure_astrolabe_for_mcp_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
|
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).
|
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
|
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||||
await configure_astrolabe_for_mcp_server(
|
await configure_astrolabe_for_mcp_server(
|
||||||
@@ -1213,9 +1292,14 @@ async def test_revoke_background_sync_access(
|
|||||||
# Step 1: Login to Nextcloud
|
# Step 1: Login to Nextcloud
|
||||||
await login_to_nextcloud(page, username, password)
|
await login_to_nextcloud(page, username, password)
|
||||||
|
|
||||||
# Step 2: Generate app password and enable background sync
|
# Step 2: Complete full authorization (OAuth Step 1 + App Password Step 2)
|
||||||
app_password = await generate_app_password(page, username)
|
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||||
await enable_background_sync_via_app_password(page, username, app_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
|
# Step 3: Verify background sync is enabled
|
||||||
assert await verify_app_password_created(username), (
|
assert await verify_app_password_created(username), (
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Integration test for Astrolabe Plotly 3D visualization with multi-user BasicAuth mode.
|
"""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:
|
This test verifies that:
|
||||||
1. User can provision background sync access via app password
|
1. User can provision background sync access via app password
|
||||||
2. Content created via MCP tools is indexed by vector sync
|
2. Content created via MCP tools is indexed by vector sync
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Integration tests for Astrolabe personal settings page buttons.
|
"""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:
|
Tests the button functionality on /settings/user/astrolabe:
|
||||||
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
|
1. Disable Indexing button (POST to /apps/astrolabe/api/revoke)
|
||||||
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
|
2. Disconnect button (POST to /apps/astrolabe/oauth/disconnect)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Integration tests for Astrolabe token refresh flow.
|
"""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)
|
Tests the token refresh mechanism between Astrolabe (Nextcloud app)
|
||||||
and the MCP server backend in a multi-user basic auth deployment.
|
and the MCP server backend in a multi-user basic auth deployment.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
"""Test Astrolabe integration with multiple MCP server deployments.
|
"""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
|
This test suite verifies that the Astrolabe app can be dynamically configured
|
||||||
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -185,7 +185,11 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# 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."""
|
"""Test that failed Nextcloud validation returns 401."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# 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)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# 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)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# 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."""
|
"""Test that invalid credentials returns 401 for deletion."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# 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."""
|
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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)
|
# 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."""
|
"""Test that rate limiting is applied per user, not globally."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"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
|
# Mock httpx client to return 401
|
||||||
|
|||||||
@@ -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.9.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/
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
20
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
require_once './vendor-bin/cs-fixer/vendor/autoload.php';
|
|
||||||
|
|
||||||
use Nextcloud\CodingStandard\Config;
|
|
||||||
|
|
||||||
$config = new Config();
|
|
||||||
$config
|
|
||||||
->getFinder()
|
|
||||||
->notPath('build')
|
|
||||||
->notPath('l10n')
|
|
||||||
->notPath('node_modules')
|
|
||||||
->notPath('src')
|
|
||||||
->notPath('vendor')
|
|
||||||
->in(__DIR__);
|
|
||||||
|
|
||||||
return $config;
|
|
||||||
Vendored
-555
@@ -1,555 +0,0 @@
|
|||||||
# Changelog - Astrolabe
|
|
||||||
|
|
||||||
All notable changes to the Astrolabe Nextcloud app will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Initial alpha release
|
|
||||||
- Semantic search across Notes, Files, Calendar, Deck, and Contacts
|
|
||||||
- Integration with Nextcloud Unified Search
|
|
||||||
- Personal settings UI for MCP server configuration
|
|
||||||
- Admin settings for global MCP server URL
|
|
||||||
- OAuth PKCE authentication flow
|
|
||||||
- Vector visualization of semantic relationships
|
|
||||||
- Hybrid search combining semantic and keyword matching
|
|
||||||
- Background content indexing
|
|
||||||
- Support for Nextcloud 30-32
|
|
||||||
|
|
||||||
### Notes
|
|
||||||
|
|
||||||
- This is an alpha release intended for early adopters and testing
|
|
||||||
- Requires external MCP server deployment
|
|
||||||
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
|
|
||||||
|
|
||||||
## astrolabe-v0.9.0 (2026-01-26)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- **scripts**: add database query helpers for development
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: resolve Psalm type errors in PDF preview code
|
|
||||||
- **astrolabe**: fix Psalm baseline and ESLint import order
|
|
||||||
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
|
|
||||||
- **astrolabe**: improve error messages for authorization issues
|
|
||||||
- **astrolabe**: rename OAuthController and fix app password check
|
|
||||||
- **tests**: improve Astrolabe integration test reliability
|
|
||||||
- **astrolabe**: update Plotly title attributes for v3 compatibility
|
|
||||||
- **deps**: update dependency plotly.js-dist-min to v3
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **api**: split management.py into domain-focused modules
|
|
||||||
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
|
|
||||||
|
|
||||||
## astrolabe-v0.8.3 (2026-01-17)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: improve token refresh error handling and validation
|
|
||||||
- **astrolabe**: delete stale tokens when refresh fails
|
|
||||||
- **astrolabe**: resolve CI failures for code quality checks
|
|
||||||
- **astrolabe**: use internal URL for OAuth token refresh
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **astrolabe**: add PHP property types to fix Psalm errors
|
|
||||||
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
|
|
||||||
|
|
||||||
## astrolabe-v0.8.2 (2026-01-16)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: Address reviewer feedback for hybrid mode
|
|
||||||
- **astrolabe**: Fix NcSelect options and CSS loading
|
|
||||||
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
|
|
||||||
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
|
|
||||||
|
|
||||||
## astrolabe-v0.8.1 (2026-01-15)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: address review feedback for Vue 3 bindings
|
|
||||||
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
|
|
||||||
- **ci**: bump helm chart version when MCP appVersion changes
|
|
||||||
|
|
||||||
## astrolabe-v0.8.0 (2026-01-15)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- Add rate limiting and extract helpers for app password endpoints
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: define appName and appVersion for @nextcloud/vue
|
|
||||||
- Add missing annotations for deck remove/unassign operations
|
|
||||||
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
|
|
||||||
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
|
|
||||||
- **deck**: Always preserve fields in update_card for partial updates
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- Use get_settings() for vector sync enabled check
|
|
||||||
- Extract storage helper and improve PHP error handling
|
|
||||||
|
|
||||||
## astrolabe-v0.7.2 (2025-12-30)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: Fix CSS loading for Nextcloud apps
|
|
||||||
|
|
||||||
## astrolabe-v0.7.1 (2025-12-30)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: Fix revoke access button HTTP method mismatch
|
|
||||||
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
|
|
||||||
- **mcp**: Move all imports to the top of modules
|
|
||||||
|
|
||||||
## astrolabe-v0.7.0 (2025-12-26)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- Remove URL rewriting in favor of proper nextcloud config
|
|
||||||
- **helm**: migrate to new environment variable naming convention
|
|
||||||
- Migrate to vue 3
|
|
||||||
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
|
|
||||||
- **helm**: add support for multi-user BasicAuth mode
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
|
|
||||||
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
|
|
||||||
- **auth**: Skip issuer validation for management API tokens
|
|
||||||
- Use settings.enable_offline_access for env var consolidation
|
|
||||||
- Add required config.py attributes
|
|
||||||
- **docker**: remove overwritehost to fix container-to-container DCR
|
|
||||||
- **deps**: update dependency @nextcloud/vue to v9
|
|
||||||
- **deps**: update dependency vue to v3
|
|
||||||
- **helm**: set OIDC client env vars when using existingSecret
|
|
||||||
- **helm**: trigger chart release workflow on helm chart tags
|
|
||||||
- **helm**: address PR #447 reviewer feedback
|
|
||||||
- **helm**: include MCP server version bumps in changelog pattern
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **auth**: Decouple BasicAuth and OAuth authentication strategies
|
|
||||||
|
|
||||||
## astrolabe-v0.6.0 (2025-12-22)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- **config**: enable DCR for multi-user BasicAuth with offline access
|
|
||||||
- **astrolabe**: implement app password provisioning for multi-user background sync
|
|
||||||
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
|
|
||||||
|
|
||||||
## astrolabe-v0.5.0 (2025-12-20)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- **auth**: add multi-user BasicAuth pass-through mode
|
|
||||||
- **astrolabe**: add dynamic MCP server configuration for testing
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **config**: address reviewer feedback
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **config**: centralize configuration validation and simplify startup
|
|
||||||
|
|
||||||
## astrolabe-v0.4.4 (2025-12-20)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: screenshots in info.xml
|
|
||||||
|
|
||||||
## astrolabe-v0.4.3 (2025-12-19)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: screenshots in info.xml
|
|
||||||
|
|
||||||
## astrolabe-v0.4.2 (2025-12-19)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: Update screenshots
|
|
||||||
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
|
|
||||||
|
|
||||||
## astrolabe-v0.4.1 (2025-12-19)
|
|
||||||
|
|
||||||
## astrolabe-v0.4.0 (2025-12-19)
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- **ci**: add --increment flag to bump scripts for manual version control
|
|
||||||
|
|
||||||
## astrolabe-v0.3.2 (2025-12-19)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: add contents:write permission to appstore workflow
|
|
||||||
|
|
||||||
## astrolabe-v0.3.1 (2025-12-19)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: update commitizen pattern to properly update info.xml version
|
|
||||||
|
|
||||||
## astrolabe-v0.3.0 (2025-12-19)
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
|
|
||||||
- **astrolabe**: info.xml
|
|
||||||
|
|
||||||
## astrolabe-v0.2.1 (2025-12-19)
|
|
||||||
|
|
||||||
### BREAKING CHANGE
|
|
||||||
|
|
||||||
- MCP server now bumps for ANY conventional commit except
|
|
||||||
those explicitly scoped to helm or astrolabe.
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **ci**: push all tags explicitly in bump workflow
|
|
||||||
- **ci**: make MCP server default bump target for all non-scoped commits
|
|
||||||
- **ci**: restrict docker build to MCP server tags only
|
|
||||||
- **ci**: correct appstore-push-action version to v1.0.4
|
|
||||||
|
|
||||||
## astrolabe-v0.2.0 (2025-12-19)
|
|
||||||
|
|
||||||
### BREAKING CHANGE
|
|
||||||
|
|
||||||
- Search algorithms now require Qdrant to be populated.
|
|
||||||
Vector sync must be enabled and documents indexed for search to work.
|
|
||||||
- All OAuth deployments must be reconfigured to specify
|
|
||||||
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
|
|
||||||
choose between multi-audience or token exchange mode.
|
|
||||||
- FASTMCP_-prefixed env vars have been replaced by CLI
|
|
||||||
arguments. Refer to the README for updated usage.
|
|
||||||
|
|
||||||
### Feat
|
|
||||||
|
|
||||||
- **ci**: implement monorepo-aware version bumping workflow
|
|
||||||
- **astrolabe**: add Nextcloud App Store deployment automation
|
|
||||||
- configure commitizen monorepo with independent versioning
|
|
||||||
- add Alembic database migration system
|
|
||||||
- make chunk modal title clickable link to documents
|
|
||||||
- add native Plotly hover styling for clickable points
|
|
||||||
- add click interactivity to Plotly 3D scatter chart
|
|
||||||
- improve chunk viewer with fixed navigation and markdown rendering
|
|
||||||
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
|
||||||
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
|
||||||
- **astrolabe**: enhance unified search and add webhook management
|
|
||||||
- **astrolabe**: add webhook management UI to admin settings
|
|
||||||
- **astrolabe**: add OAuth token refresh and webhook presets
|
|
||||||
- **search**: add file_path metadata and chunk offsets to search results
|
|
||||||
- **astrolabe**: use proper icons and thumbnails in unified search
|
|
||||||
- **astrolabe**: add admin search settings and enhanced UI
|
|
||||||
- **astrolabe**: add unified search provider with clickable file links
|
|
||||||
- **astrolabe**: add 3D PCA visualization for semantic search
|
|
||||||
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
|
||||||
- **vector-sync**: enable background sync in OAuth mode
|
|
||||||
- **vector**: add Deck card vector search with visualization support
|
|
||||||
- **vector-viz**: add news_item support for links and chunk expansion
|
|
||||||
- add MCP tool annotations for enhanced UX
|
|
||||||
- **news**: add Nextcloud News app integration
|
|
||||||
- Add tag management methods to WebDAV client
|
|
||||||
- Add OpenAI provider support for embeddings and generation
|
|
||||||
- Add Smithery CLI deployment support
|
|
||||||
- Implement ADR-016 Smithery stateless deployment mode
|
|
||||||
- Add context expansion to semantic search with chunk overlap removal
|
|
||||||
- Use Ollama native batch API in embed_batch()
|
|
||||||
- Implement Qdrant placeholder state management
|
|
||||||
- Switch files to use numeric IDs with file_path resolution
|
|
||||||
- Implement per-chunk vector visualization with context expansion
|
|
||||||
- Improve vector visualization with static assets and fixes
|
|
||||||
- Redesign UI to match Nextcloud ecosystem aesthetic
|
|
||||||
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
|
||||||
- **viz**: Add dual-score display and improve UI controls
|
|
||||||
- add configurable fusion algorithms for BM25 hybrid search
|
|
||||||
- add chunk position tracking to vector indexing and search
|
|
||||||
- add vector viz template and chunk context endpoint
|
|
||||||
- add unified provider architecture with Amazon Bedrock support
|
|
||||||
- add concurrent uploads and --force flag to upload command
|
|
||||||
- implement RAG evaluation framework with CLI tooling
|
|
||||||
- Add OpenTelemetry tracing to @instrument_tool decorator
|
|
||||||
- Implement BM25 hybrid search with native Qdrant RRF fusion
|
|
||||||
- Normalize hybrid search RRF scores to 0-1 range
|
|
||||||
- Enhance vector visualization UI and parallelize search verification
|
|
||||||
- Add Vector Viz tab to app home page
|
|
||||||
- Add vector visualization pane with multi-select document types
|
|
||||||
- Implement custom PCA to remove sklearn dependency
|
|
||||||
- Add multi-document Protocol with cross-app search support
|
|
||||||
- Update nc_semantic_search tool with algorithm selection
|
|
||||||
- Implement unified search algorithm module
|
|
||||||
- Enable SSE transport for mcp service and update test fixtures
|
|
||||||
- Complete Phase 5 - Instrument all 93 MCP tools
|
|
||||||
- Add instrumentation decorator and apply to notes tools (Phase 5)
|
|
||||||
- Add OAuth token and database metrics (Phases 3-4)
|
|
||||||
- Add metrics instrumentation for queue, health, and database operations
|
|
||||||
- Add Grafana dashboard and vector sync metric instrumentation
|
|
||||||
- **ollama**: Pull model on startup if not available in ollama
|
|
||||||
- add dynamic vector sync status updates with htmx polling
|
|
||||||
- add webhook management UI and BeforeNodeDeletedEvent support
|
|
||||||
- validate Nextcloud webhook schemas and document findings
|
|
||||||
- skip tracing for health and metrics endpoints
|
|
||||||
- **helm**: Add document chunking configuration
|
|
||||||
- **vector**: Add configurable chunk size and overlap for document embedding
|
|
||||||
- **vector**: Support multiple embedding models with auto-generated collection names
|
|
||||||
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
|
|
||||||
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
|
|
||||||
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
|
|
||||||
- add Qdrant local mode support with in-memory and persistent storage
|
|
||||||
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
|
|
||||||
- implement MCP sampling for semantic search RAG (ADR-008)
|
|
||||||
- add optional vector database and semantic search to helm chart
|
|
||||||
- add vector sync processing status to /user/page endpoint
|
|
||||||
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
|
|
||||||
- implement vector sync scanner and processor (ADR-007 Phase 2)
|
|
||||||
- add real elicitation integration test with python-sdk MCP client
|
|
||||||
- unify session architecture and enhance login status visibility
|
|
||||||
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
|
|
||||||
- add scope protection to OAuth provisioning tools
|
|
||||||
- enable authorization services for token exchange in Keycloak
|
|
||||||
- implement scope-based audience mapping and RFC 9728 support
|
|
||||||
- integrate token exchange into MCP server application
|
|
||||||
- implement RFC 8693 Standard Token Exchange for Keycloak
|
|
||||||
- Add userinfo route/page
|
|
||||||
- add browser-based user info page with separate OAuth flow
|
|
||||||
- Implement ADR-004 Progressive Consent foundation (partial)
|
|
||||||
- Complete ADR-004 Progressive Consent OAuth flows implementation
|
|
||||||
- Implement ADR-004 Progressive Consent foundation components
|
|
||||||
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
|
|
||||||
- Auto-configure impersonation role in Keycloak realm import
|
|
||||||
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
|
|
||||||
- Add Keycloak external IdP integration with custom scopes
|
|
||||||
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
|
|
||||||
- Add Keycloak OAuth provider support with refresh token storage
|
|
||||||
- **server**: Add /live & /health endpoints
|
|
||||||
- Initialize helm chart
|
|
||||||
- Add text processing background worker for telling client about progress
|
|
||||||
- **auth**: Add support for client registration deletion
|
|
||||||
- Split read/write scopes into app:read/write scopes
|
|
||||||
- Enable token introspection for opaque tokens
|
|
||||||
- **server**: Add support for custom OIDC scopes and permissions via JWTs
|
|
||||||
- Initialize JWT-scoped tools
|
|
||||||
- **caldav**: Add support for tasks
|
|
||||||
- **webdav**: Add search and list favorite response tools
|
|
||||||
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
|
|
||||||
- Add Groups API client
|
|
||||||
- add sharing API client and server tools
|
|
||||||
- **server**: Experimental support for OAuth2/OIDC authentication
|
|
||||||
- **users**: Initialize user API client
|
|
||||||
- **server**: Add support for `streamable-http` transport type
|
|
||||||
- Add WebDAV resource copy functionality
|
|
||||||
- Add WebDAV resource move/rename functionality
|
|
||||||
- **deck**: Add support for stack, cards, labels
|
|
||||||
- **deck**: Initialize Deck app client/server
|
|
||||||
- **cli**: Replace `mcp run` with click CLI and runtime options
|
|
||||||
- **client**: Preserve fields when modifying contacts/calendar resources
|
|
||||||
- **server**: Add structured output to all tool/resource output
|
|
||||||
- **contacts**: Initialize Contacts App
|
|
||||||
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
|
|
||||||
- Update webdav client create_directory method to handle recursive directories
|
|
||||||
- **webdav**: add complete file system support
|
|
||||||
- Add TablesClient and associated tools
|
|
||||||
- Switch to using async client
|
|
||||||
- **notes**: Add append to note functionality
|
|
||||||
|
|
||||||
### Fix
|
|
||||||
|
|
||||||
- **ci**: improve versioning and error handling
|
|
||||||
- **ci**: address critical workflow and validation issues
|
|
||||||
- **astrolabe**: address code review feedback
|
|
||||||
- **security**: address critical security issues from PR #401 code review
|
|
||||||
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
|
||||||
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
|
||||||
- resolve type checking warnings for CI
|
|
||||||
- move Alembic to package submodule for Docker compatibility
|
|
||||||
- update unified search results to match chunk viz display
|
|
||||||
- **astrolabe**: handle OAuth refresh token rotation
|
|
||||||
- address critical code review issues (4 fixes)
|
|
||||||
- resolve CI linting issues for Astroglobe
|
|
||||||
- **news**: revert get_item() to use get_items() + filter
|
|
||||||
- Disable DNS rebinding protection for containerized deployments
|
|
||||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
|
||||||
- address PR review feedback
|
|
||||||
- Update lockfile
|
|
||||||
- Revert mcp version <1.23
|
|
||||||
- resolve all type checking errors (8 errors fixed)
|
|
||||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
|
||||||
- **deps**: update dependency pillow to v12
|
|
||||||
- Add rate limit retry logic to OpenAI provider
|
|
||||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
|
||||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
|
||||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
|
||||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
|
||||||
- **smithery**: Enable JSON response format for scanner compatibility
|
|
||||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
|
||||||
- **smithery**: Use container runtime pattern for config discovery
|
|
||||||
- Add Smithery lifespan and auth mode detection
|
|
||||||
- Use alpha_composite for proper RGBA highlight blending
|
|
||||||
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
|
||||||
- Centralize PDF processing and generate separate images per chunk
|
|
||||||
- Set is_placeholder=False in processor to fix search filtering
|
|
||||||
- Increase placeholder staleness threshold to 5x scan interval
|
|
||||||
- Add placeholder staleness check to prevent duplicate processing
|
|
||||||
- Use empty SparseVector instead of None for placeholders
|
|
||||||
- Return empty array instead of null for query_coords when no results
|
|
||||||
- Align PDF text extraction between indexing and context expansion
|
|
||||||
- Update models and viz to use int-only doc_id
|
|
||||||
- Reconstruct full content for notes to match indexed offsets
|
|
||||||
- Add async/await, PDF metadata, and type safety fixes
|
|
||||||
- **deps**: update dependency mcp to >=1.22,<1.23
|
|
||||||
- Improve 3D plot rendering with explicit dimensions and window resize support
|
|
||||||
- Preserve 3D plot camera and improve documentation
|
|
||||||
- Preserve 3D plot camera position and fix CSS loading
|
|
||||||
- prevent infinite loop in DocumentChunker with position tracking
|
|
||||||
- Relax SearchResult validation to support DBSF fusion scores > 1.0
|
|
||||||
- suppress Starlette middleware type warnings in ty checker
|
|
||||||
- download qrels from BEIR ZIP instead of HuggingFace
|
|
||||||
- Handle named vectors in visualization and semantic search
|
|
||||||
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
|
|
||||||
- Update viz routes to use BM25 hybrid search after refactor
|
|
||||||
- Reorder tabs and fix viz pane session access
|
|
||||||
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
|
|
||||||
- return all notes when search query is empty
|
|
||||||
- Move grafana_folder from labels to annotations
|
|
||||||
- add dynamic dimension detection for Ollama embedding models
|
|
||||||
- improve webapp tab UI with CSS Grid and viewport-filling container
|
|
||||||
- add retry logic for ETag conflicts in category change test
|
|
||||||
- optimize Notes API pagination with pruneBefore parameter
|
|
||||||
- Support in-memory Qdrant for CI testing
|
|
||||||
- **helm**: Set default strategy to Recreate
|
|
||||||
- **observability**: isolate metrics endpoint to dedicated port
|
|
||||||
- **readiness**: Only check external Qdrant in network mode
|
|
||||||
- **vector**: Handle missing 'modified' field in notes gracefully
|
|
||||||
- **ci**: Use helm dependency build instead of update to use Chart.lock
|
|
||||||
- **helm**: update Qdrant dependency condition to match new mode structure
|
|
||||||
- **ci**: add Helm repository setup to chart release workflow
|
|
||||||
- implement deletion grace period and vector sync status tool
|
|
||||||
- remove unnecessary urllib3<2.0 constraint
|
|
||||||
- integrate vector sync tasks with Starlette lifespan for streamable-http
|
|
||||||
- **deps**: update dependency mcp to >=1.21,<1.22
|
|
||||||
- Consolidate OAuth callbacks and implement PKCE for all flows
|
|
||||||
- Implement proper OAuth resource parameters and PRM-based discovery
|
|
||||||
- Simplify token verifier to be RFC 7519 compliant
|
|
||||||
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
|
|
||||||
- Correct OAuth token audience validation for multi-audience mode
|
|
||||||
- **deps**: update dependency mcp to >=1.20,<1.21
|
|
||||||
- add missing await for get_nextcloud_client in capabilities resource
|
|
||||||
- use valid Fernet encryption keys in token exchange tests
|
|
||||||
- accept resource URL in token audience for Nextcloud JWT tokens
|
|
||||||
- remove token-exchange-nextcloud scope and accept tokens without audience
|
|
||||||
- move audience mapper from scope to nextcloud-mcp-server client
|
|
||||||
- move token-exchange-nextcloud from default to optional scopes
|
|
||||||
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
|
|
||||||
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
|
|
||||||
- correct OAuth token audience validation using RFC 8707 resource parameter
|
|
||||||
- remove remaining references to deleted oauth_callback and oauth_token
|
|
||||||
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
|
|
||||||
- browser OAuth userinfo endpoint and refresh token rotation
|
|
||||||
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
|
|
||||||
- make provisioning checks opt-in (default false)
|
|
||||||
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
|
|
||||||
- Complete Keycloak external IdP integration with all tests passing
|
|
||||||
- Complete Keycloak external IdP integration with all tests passing
|
|
||||||
- Update DCR token_type tests for OIDC app changes
|
|
||||||
- **helm**: Remove image tag overide
|
|
||||||
- **helm**: Update helm chart with extraArgs
|
|
||||||
- Update helm chart variables
|
|
||||||
- **helm**: Update helm version with release
|
|
||||||
- **helm**: Update helm version with release
|
|
||||||
- **helm**: Update helm version with release
|
|
||||||
- **helm**: Update helm version with release
|
|
||||||
- Trigger release
|
|
||||||
- Add support for RFC 7592 client registration and deletion
|
|
||||||
- Update webdav models for proper serialization
|
|
||||||
- **deps**: update dependency mcp to >=1.19,<1.20
|
|
||||||
- Add CORS middleware to allow browser-based clients like MCP Inspector
|
|
||||||
- Use occ-created OAuth clients with allowed_scopes for all tests
|
|
||||||
- Separate OAuth fixtures for opaque vs JWT tokens
|
|
||||||
- **caldav**: Fix caldav search() due to missing todos
|
|
||||||
- **caldav**: Check that calendar exists after creation to avoid race condition
|
|
||||||
- **caldav**: Properly parse datetimes as vDDDTypes
|
|
||||||
- Increase HTTP client timeout to 30s
|
|
||||||
- Handle RequestError in mcp tools
|
|
||||||
- **deps**: update dependency mcp to >=1.18,<1.19
|
|
||||||
- **deps**: update dependency pillow to v12
|
|
||||||
- **oauth**: Remove the option to force_register new clients
|
|
||||||
- Update user/groups API to OCS v2
|
|
||||||
- **deps**: update dependency mcp to >=1.17,<1.18
|
|
||||||
- **deps**: update dependency mcp to >=1.16,<1.17
|
|
||||||
- **deps**: update dependency mcp to >=1.15,<1.16
|
|
||||||
- **docker**: Provide --host 0.0.0.0 in default docker image
|
|
||||||
- **deps**: update dependency mcp to >=1.13,<1.14
|
|
||||||
- **server**: Replace ErrorResponses with standard McpErrors
|
|
||||||
- **notes**: Include ETags in responses to avoid accidently updates
|
|
||||||
- **notes**: Remove note contents from responses to reduce token usage
|
|
||||||
- **model**: Serialize timestamps in RFC3339 format
|
|
||||||
- **client**: Use paging to fetch all notes
|
|
||||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
|
||||||
- **calendar**: Fix iCalendar date vs datetime format
|
|
||||||
- **calendar**: Remove try/except in calendar API
|
|
||||||
- apply ruff formatting to pass CI checks
|
|
||||||
- **calendar**: address PR feedback from maintainer
|
|
||||||
- apply ruff formatting to test_webdav_operations.py
|
|
||||||
- **deps**: update dependency mcp to >=1.10,<1.11
|
|
||||||
- update tests
|
|
||||||
- Commitizen release process
|
|
||||||
- Do not update dependencies when running in Dockerfile
|
|
||||||
- Configure logging
|
|
||||||
- Limit search results to notes with score > 0.5
|
|
||||||
- Install deps before checking service
|
|
||||||
- **deps**: update dependency mcp to >=1.9,<1.10
|
|
||||||
|
|
||||||
### Refactor
|
|
||||||
|
|
||||||
- **astrolabe**: extract PDF viewer to dedicated component
|
|
||||||
- **astrolabe**: reframe UI as semantic search service
|
|
||||||
- **news**: simplify vector sync to fetch all items
|
|
||||||
- Move background tasks to server lifespan and deprecate SSE transport
|
|
||||||
- Simplify PDF text extraction with single to_markdown call
|
|
||||||
- migrate asyncio to anyio for consistent structured concurrency
|
|
||||||
- replace httpx client with NextcloudClient in upload command
|
|
||||||
- Optimize Nextcloud access verification with centralized filtering
|
|
||||||
- Make all search algorithms query Qdrant payload, not Nextcloud
|
|
||||||
- move webapp from /user/page to /app
|
|
||||||
- consolidate database storage for webhooks and OAuth tokens
|
|
||||||
- simplify OpenTelemetry tracing configuration
|
|
||||||
- migrate vector sync from asyncio.Queue to anyio memory object streams
|
|
||||||
- update to Qdrant query_points API and fix Playwright Keycloak login
|
|
||||||
- Eliminate duplicate validation logic in UnifiedTokenVerifier
|
|
||||||
- integrate token exchange into unified get_client() pattern
|
|
||||||
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
|
|
||||||
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
|
|
||||||
- Unify OAuth configuration to be provider-agnostic
|
|
||||||
- Transform document parsing into pluggable processor architecture
|
|
||||||
- Update JWT client to use DCR, re-enable tool filtering
|
|
||||||
- Migrate from internal CalendarClient to caldav library
|
|
||||||
- Unify logging & remove factory deployment
|
|
||||||
- Add tools for all resources to enable tool-only workflows
|
|
||||||
- Add `http` to --transport option
|
|
||||||
- Use _make_request where available
|
|
||||||
- **calendar**: optimize logging for production readiness
|
|
||||||
- Modularize NC and Notes app client
|
|
||||||
|
|
||||||
### Perf
|
|
||||||
|
|
||||||
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
|
||||||
- **news**: use direct API endpoint for get_item()
|
|
||||||
- Optimize vector viz search performance
|
|
||||||
- Optimize PDF processing with parallel extraction and single-render highlights
|
|
||||||
- Eliminate double-fetching in semantic search sampling
|
|
||||||
- fix vector viz search performance and visual encoding
|
|
||||||
- make note deletion concurrent in upload --force
|
|
||||||
- Exclude vector-sync status polling from distributed tracing
|
|
||||||
- **notes**: Improve notes search performance using async iterators
|
|
||||||
-9
@@ -1,9 +0,0 @@
|
|||||||
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
|
|
||||||
|
|
||||||
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
|
|
||||||
|
|
||||||
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
|
|
||||||
|
|
||||||
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
|
|
||||||
|
|
||||||
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.
|
|
||||||
Vendored
-661
@@ -1,661 +0,0 @@
|
|||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
|
||||||
Version 3, 19 November 2007
|
|
||||||
|
|
||||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
|
||||||
Everyone is permitted to copy and distribute verbatim copies
|
|
||||||
of this license document, but changing it is not allowed.
|
|
||||||
|
|
||||||
Preamble
|
|
||||||
|
|
||||||
The GNU Affero General Public License is a free, copyleft license for
|
|
||||||
software and other kinds of works, specifically designed to ensure
|
|
||||||
cooperation with the community in the case of network server software.
|
|
||||||
|
|
||||||
The licenses for most software and other practical works are designed
|
|
||||||
to take away your freedom to share and change the works. By contrast,
|
|
||||||
our General Public Licenses are intended to guarantee your freedom to
|
|
||||||
share and change all versions of a program--to make sure it remains free
|
|
||||||
software for all its users.
|
|
||||||
|
|
||||||
When we speak of free software, we are referring to freedom, not
|
|
||||||
price. Our General Public Licenses are designed to make sure that you
|
|
||||||
have the freedom to distribute copies of free software (and charge for
|
|
||||||
them if you wish), that you receive source code or can get it if you
|
|
||||||
want it, that you can change the software or use pieces of it in new
|
|
||||||
free programs, and that you know you can do these things.
|
|
||||||
|
|
||||||
Developers that use our General Public Licenses protect your rights
|
|
||||||
with two steps: (1) assert copyright on the software, and (2) offer
|
|
||||||
you this License which gives you legal permission to copy, distribute
|
|
||||||
and/or modify the software.
|
|
||||||
|
|
||||||
A secondary benefit of defending all users' freedom is that
|
|
||||||
improvements made in alternate versions of the program, if they
|
|
||||||
receive widespread use, become available for other developers to
|
|
||||||
incorporate. Many developers of free software are heartened and
|
|
||||||
encouraged by the resulting cooperation. However, in the case of
|
|
||||||
software used on network servers, this result may fail to come about.
|
|
||||||
The GNU General Public License permits making a modified version and
|
|
||||||
letting the public access it on a server without ever releasing its
|
|
||||||
source code to the public.
|
|
||||||
|
|
||||||
The GNU Affero General Public License is designed specifically to
|
|
||||||
ensure that, in such cases, the modified source code becomes available
|
|
||||||
to the community. It requires the operator of a network server to
|
|
||||||
provide the source code of the modified version running there to the
|
|
||||||
users of that server. Therefore, public use of a modified version, on
|
|
||||||
a publicly accessible server, gives the public access to the source
|
|
||||||
code of the modified version.
|
|
||||||
|
|
||||||
An older license, called the Affero General Public License and
|
|
||||||
published by Affero, was designed to accomplish similar goals. This is
|
|
||||||
a different license, not a version of the Affero GPL, but Affero has
|
|
||||||
released a new version of the Affero GPL which permits relicensing under
|
|
||||||
this license.
|
|
||||||
|
|
||||||
The precise terms and conditions for copying, distribution and
|
|
||||||
modification follow.
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
0. Definitions.
|
|
||||||
|
|
||||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
|
||||||
|
|
||||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
|
||||||
works, such as semiconductor masks.
|
|
||||||
|
|
||||||
"The Program" refers to any copyrightable work licensed under this
|
|
||||||
License. Each licensee is addressed as "you". "Licensees" and
|
|
||||||
"recipients" may be individuals or organizations.
|
|
||||||
|
|
||||||
To "modify" a work means to copy from or adapt all or part of the work
|
|
||||||
in a fashion requiring copyright permission, other than the making of an
|
|
||||||
exact copy. The resulting work is called a "modified version" of the
|
|
||||||
earlier work or a work "based on" the earlier work.
|
|
||||||
|
|
||||||
A "covered work" means either the unmodified Program or a work based
|
|
||||||
on the Program.
|
|
||||||
|
|
||||||
To "propagate" a work means to do anything with it that, without
|
|
||||||
permission, would make you directly or secondarily liable for
|
|
||||||
infringement under applicable copyright law, except executing it on a
|
|
||||||
computer or modifying a private copy. Propagation includes copying,
|
|
||||||
distribution (with or without modification), making available to the
|
|
||||||
public, and in some countries other activities as well.
|
|
||||||
|
|
||||||
To "convey" a work means any kind of propagation that enables other
|
|
||||||
parties to make or receive copies. Mere interaction with a user through
|
|
||||||
a computer network, with no transfer of a copy, is not conveying.
|
|
||||||
|
|
||||||
An interactive user interface displays "Appropriate Legal Notices"
|
|
||||||
to the extent that it includes a convenient and prominently visible
|
|
||||||
feature that (1) displays an appropriate copyright notice, and (2)
|
|
||||||
tells the user that there is no warranty for the work (except to the
|
|
||||||
extent that warranties are provided), that licensees may convey the
|
|
||||||
work under this License, and how to view a copy of this License. If
|
|
||||||
the interface presents a list of user commands or options, such as a
|
|
||||||
menu, a prominent item in the list meets this criterion.
|
|
||||||
|
|
||||||
1. Source Code.
|
|
||||||
|
|
||||||
The "source code" for a work means the preferred form of the work
|
|
||||||
for making modifications to it. "Object code" means any non-source
|
|
||||||
form of a work.
|
|
||||||
|
|
||||||
A "Standard Interface" means an interface that either is an official
|
|
||||||
standard defined by a recognized standards body, or, in the case of
|
|
||||||
interfaces specified for a particular programming language, one that
|
|
||||||
is widely used among developers working in that language.
|
|
||||||
|
|
||||||
The "System Libraries" of an executable work include anything, other
|
|
||||||
than the work as a whole, that (a) is included in the normal form of
|
|
||||||
packaging a Major Component, but which is not part of that Major
|
|
||||||
Component, and (b) serves only to enable use of the work with that
|
|
||||||
Major Component, or to implement a Standard Interface for which an
|
|
||||||
implementation is available to the public in source code form. A
|
|
||||||
"Major Component", in this context, means a major essential component
|
|
||||||
(kernel, window system, and so on) of the specific operating system
|
|
||||||
(if any) on which the executable work runs, or a compiler used to
|
|
||||||
produce the work, or an object code interpreter used to run it.
|
|
||||||
|
|
||||||
The "Corresponding Source" for a work in object code form means all
|
|
||||||
the source code needed to generate, install, and (for an executable
|
|
||||||
work) run the object code and to modify the work, including scripts to
|
|
||||||
control those activities. However, it does not include the work's
|
|
||||||
System Libraries, or general-purpose tools or generally available free
|
|
||||||
programs which are used unmodified in performing those activities but
|
|
||||||
which are not part of the work. For example, Corresponding Source
|
|
||||||
includes interface definition files associated with source files for
|
|
||||||
the work, and the source code for shared libraries and dynamically
|
|
||||||
linked subprograms that the work is specifically designed to require,
|
|
||||||
such as by intimate data communication or control flow between those
|
|
||||||
subprograms and other parts of the work.
|
|
||||||
|
|
||||||
The Corresponding Source need not include anything that users
|
|
||||||
can regenerate automatically from other parts of the Corresponding
|
|
||||||
Source.
|
|
||||||
|
|
||||||
The Corresponding Source for a work in source code form is that
|
|
||||||
same work.
|
|
||||||
|
|
||||||
2. Basic Permissions.
|
|
||||||
|
|
||||||
All rights granted under this License are granted for the term of
|
|
||||||
copyright on the Program, and are irrevocable provided the stated
|
|
||||||
conditions are met. This License explicitly affirms your unlimited
|
|
||||||
permission to run the unmodified Program. The output from running a
|
|
||||||
covered work is covered by this License only if the output, given its
|
|
||||||
content, constitutes a covered work. This License acknowledges your
|
|
||||||
rights of fair use or other equivalent, as provided by copyright law.
|
|
||||||
|
|
||||||
You may make, run and propagate covered works that you do not
|
|
||||||
convey, without conditions so long as your license otherwise remains
|
|
||||||
in force. You may convey covered works to others for the sole purpose
|
|
||||||
of having them make modifications exclusively for you, or provide you
|
|
||||||
with facilities for running those works, provided that you comply with
|
|
||||||
the terms of this License in conveying all material for which you do
|
|
||||||
not control copyright. Those thus making or running the covered works
|
|
||||||
for you must do so exclusively on your behalf, under your direction
|
|
||||||
and control, on terms that prohibit them from making any copies of
|
|
||||||
your copyrighted material outside their relationship with you.
|
|
||||||
|
|
||||||
Conveying under any other circumstances is permitted solely under
|
|
||||||
the conditions stated below. Sublicensing is not allowed; section 10
|
|
||||||
makes it unnecessary.
|
|
||||||
|
|
||||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
|
||||||
|
|
||||||
No covered work shall be deemed part of an effective technological
|
|
||||||
measure under any applicable law fulfilling obligations under article
|
|
||||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
|
||||||
similar laws prohibiting or restricting circumvention of such
|
|
||||||
measures.
|
|
||||||
|
|
||||||
When you convey a covered work, you waive any legal power to forbid
|
|
||||||
circumvention of technological measures to the extent such circumvention
|
|
||||||
is effected by exercising rights under this License with respect to
|
|
||||||
the covered work, and you disclaim any intention to limit operation or
|
|
||||||
modification of the work as a means of enforcing, against the work's
|
|
||||||
users, your or third parties' legal rights to forbid circumvention of
|
|
||||||
technological measures.
|
|
||||||
|
|
||||||
4. Conveying Verbatim Copies.
|
|
||||||
|
|
||||||
You may convey verbatim copies of the Program's source code as you
|
|
||||||
receive it, in any medium, provided that you conspicuously and
|
|
||||||
appropriately publish on each copy an appropriate copyright notice;
|
|
||||||
keep intact all notices stating that this License and any
|
|
||||||
non-permissive terms added in accord with section 7 apply to the code;
|
|
||||||
keep intact all notices of the absence of any warranty; and give all
|
|
||||||
recipients a copy of this License along with the Program.
|
|
||||||
|
|
||||||
You may charge any price or no price for each copy that you convey,
|
|
||||||
and you may offer support or warranty protection for a fee.
|
|
||||||
|
|
||||||
5. Conveying Modified Source Versions.
|
|
||||||
|
|
||||||
You may convey a work based on the Program, or the modifications to
|
|
||||||
produce it from the Program, in the form of source code under the
|
|
||||||
terms of section 4, provided that you also meet all of these conditions:
|
|
||||||
|
|
||||||
a) The work must carry prominent notices stating that you modified
|
|
||||||
it, and giving a relevant date.
|
|
||||||
|
|
||||||
b) The work must carry prominent notices stating that it is
|
|
||||||
released under this License and any conditions added under section
|
|
||||||
7. This requirement modifies the requirement in section 4 to
|
|
||||||
"keep intact all notices".
|
|
||||||
|
|
||||||
c) You must license the entire work, as a whole, under this
|
|
||||||
License to anyone who comes into possession of a copy. This
|
|
||||||
License will therefore apply, along with any applicable section 7
|
|
||||||
additional terms, to the whole of the work, and all its parts,
|
|
||||||
regardless of how they are packaged. This License gives no
|
|
||||||
permission to license the work in any other way, but it does not
|
|
||||||
invalidate such permission if you have separately received it.
|
|
||||||
|
|
||||||
d) If the work has interactive user interfaces, each must display
|
|
||||||
Appropriate Legal Notices; however, if the Program has interactive
|
|
||||||
interfaces that do not display Appropriate Legal Notices, your
|
|
||||||
work need not make them do so.
|
|
||||||
|
|
||||||
A compilation of a covered work with other separate and independent
|
|
||||||
works, which are not by their nature extensions of the covered work,
|
|
||||||
and which are not combined with it such as to form a larger program,
|
|
||||||
in or on a volume of a storage or distribution medium, is called an
|
|
||||||
"aggregate" if the compilation and its resulting copyright are not
|
|
||||||
used to limit the access or legal rights of the compilation's users
|
|
||||||
beyond what the individual works permit. Inclusion of a covered work
|
|
||||||
in an aggregate does not cause this License to apply to the other
|
|
||||||
parts of the aggregate.
|
|
||||||
|
|
||||||
6. Conveying Non-Source Forms.
|
|
||||||
|
|
||||||
You may convey a covered work in object code form under the terms
|
|
||||||
of sections 4 and 5, provided that you also convey the
|
|
||||||
machine-readable Corresponding Source under the terms of this License,
|
|
||||||
in one of these ways:
|
|
||||||
|
|
||||||
a) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by the
|
|
||||||
Corresponding Source fixed on a durable physical medium
|
|
||||||
customarily used for software interchange.
|
|
||||||
|
|
||||||
b) Convey the object code in, or embodied in, a physical product
|
|
||||||
(including a physical distribution medium), accompanied by a
|
|
||||||
written offer, valid for at least three years and valid for as
|
|
||||||
long as you offer spare parts or customer support for that product
|
|
||||||
model, to give anyone who possesses the object code either (1) a
|
|
||||||
copy of the Corresponding Source for all the software in the
|
|
||||||
product that is covered by this License, on a durable physical
|
|
||||||
medium customarily used for software interchange, for a price no
|
|
||||||
more than your reasonable cost of physically performing this
|
|
||||||
conveying of source, or (2) access to copy the
|
|
||||||
Corresponding Source from a network server at no charge.
|
|
||||||
|
|
||||||
c) Convey individual copies of the object code with a copy of the
|
|
||||||
written offer to provide the Corresponding Source. This
|
|
||||||
alternative is allowed only occasionally and noncommercially, and
|
|
||||||
only if you received the object code with such an offer, in accord
|
|
||||||
with subsection 6b.
|
|
||||||
|
|
||||||
d) Convey the object code by offering access from a designated
|
|
||||||
place (gratis or for a charge), and offer equivalent access to the
|
|
||||||
Corresponding Source in the same way through the same place at no
|
|
||||||
further charge. You need not require recipients to copy the
|
|
||||||
Corresponding Source along with the object code. If the place to
|
|
||||||
copy the object code is a network server, the Corresponding Source
|
|
||||||
may be on a different server (operated by you or a third party)
|
|
||||||
that supports equivalent copying facilities, provided you maintain
|
|
||||||
clear directions next to the object code saying where to find the
|
|
||||||
Corresponding Source. Regardless of what server hosts the
|
|
||||||
Corresponding Source, you remain obligated to ensure that it is
|
|
||||||
available for as long as needed to satisfy these requirements.
|
|
||||||
|
|
||||||
e) Convey the object code using peer-to-peer transmission, provided
|
|
||||||
you inform other peers where the object code and Corresponding
|
|
||||||
Source of the work are being offered to the general public at no
|
|
||||||
charge under subsection 6d.
|
|
||||||
|
|
||||||
A separable portion of the object code, whose source code is excluded
|
|
||||||
from the Corresponding Source as a System Library, need not be
|
|
||||||
included in conveying the object code work.
|
|
||||||
|
|
||||||
A "User Product" is either (1) a "consumer product", which means any
|
|
||||||
tangible personal property which is normally used for personal, family,
|
|
||||||
or household purposes, or (2) anything designed or sold for incorporation
|
|
||||||
into a dwelling. In determining whether a product is a consumer product,
|
|
||||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
|
||||||
product received by a particular user, "normally used" refers to a
|
|
||||||
typical or common use of that class of product, regardless of the status
|
|
||||||
of the particular user or of the way in which the particular user
|
|
||||||
actually uses, or expects or is expected to use, the product. A product
|
|
||||||
is a consumer product regardless of whether the product has substantial
|
|
||||||
commercial, industrial or non-consumer uses, unless such uses represent
|
|
||||||
the only significant mode of use of the product.
|
|
||||||
|
|
||||||
"Installation Information" for a User Product means any methods,
|
|
||||||
procedures, authorization keys, or other information required to install
|
|
||||||
and execute modified versions of a covered work in that User Product from
|
|
||||||
a modified version of its Corresponding Source. The information must
|
|
||||||
suffice to ensure that the continued functioning of the modified object
|
|
||||||
code is in no case prevented or interfered with solely because
|
|
||||||
modification has been made.
|
|
||||||
|
|
||||||
If you convey an object code work under this section in, or with, or
|
|
||||||
specifically for use in, a User Product, and the conveying occurs as
|
|
||||||
part of a transaction in which the right of possession and use of the
|
|
||||||
User Product is transferred to the recipient in perpetuity or for a
|
|
||||||
fixed term (regardless of how the transaction is characterized), the
|
|
||||||
Corresponding Source conveyed under this section must be accompanied
|
|
||||||
by the Installation Information. But this requirement does not apply
|
|
||||||
if neither you nor any third party retains the ability to install
|
|
||||||
modified object code on the User Product (for example, the work has
|
|
||||||
been installed in ROM).
|
|
||||||
|
|
||||||
The requirement to provide Installation Information does not include a
|
|
||||||
requirement to continue to provide support service, warranty, or updates
|
|
||||||
for a work that has been modified or installed by the recipient, or for
|
|
||||||
the User Product in which it has been modified or installed. Access to a
|
|
||||||
network may be denied when the modification itself materially and
|
|
||||||
adversely affects the operation of the network or violates the rules and
|
|
||||||
protocols for communication across the network.
|
|
||||||
|
|
||||||
Corresponding Source conveyed, and Installation Information provided,
|
|
||||||
in accord with this section must be in a format that is publicly
|
|
||||||
documented (and with an implementation available to the public in
|
|
||||||
source code form), and must require no special password or key for
|
|
||||||
unpacking, reading or copying.
|
|
||||||
|
|
||||||
7. Additional Terms.
|
|
||||||
|
|
||||||
"Additional permissions" are terms that supplement the terms of this
|
|
||||||
License by making exceptions from one or more of its conditions.
|
|
||||||
Additional permissions that are applicable to the entire Program shall
|
|
||||||
be treated as though they were included in this License, to the extent
|
|
||||||
that they are valid under applicable law. If additional permissions
|
|
||||||
apply only to part of the Program, that part may be used separately
|
|
||||||
under those permissions, but the entire Program remains governed by
|
|
||||||
this License without regard to the additional permissions.
|
|
||||||
|
|
||||||
When you convey a copy of a covered work, you may at your option
|
|
||||||
remove any additional permissions from that copy, or from any part of
|
|
||||||
it. (Additional permissions may be written to require their own
|
|
||||||
removal in certain cases when you modify the work.) You may place
|
|
||||||
additional permissions on material, added by you to a covered work,
|
|
||||||
for which you have or can give appropriate copyright permission.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, for material you
|
|
||||||
add to a covered work, you may (if authorized by the copyright holders of
|
|
||||||
that material) supplement the terms of this License with terms:
|
|
||||||
|
|
||||||
a) Disclaiming warranty or limiting liability differently from the
|
|
||||||
terms of sections 15 and 16 of this License; or
|
|
||||||
|
|
||||||
b) Requiring preservation of specified reasonable legal notices or
|
|
||||||
author attributions in that material or in the Appropriate Legal
|
|
||||||
Notices displayed by works containing it; or
|
|
||||||
|
|
||||||
c) Prohibiting misrepresentation of the origin of that material, or
|
|
||||||
requiring that modified versions of such material be marked in
|
|
||||||
reasonable ways as different from the original version; or
|
|
||||||
|
|
||||||
d) Limiting the use for publicity purposes of names of licensors or
|
|
||||||
authors of the material; or
|
|
||||||
|
|
||||||
e) Declining to grant rights under trademark law for use of some
|
|
||||||
trade names, trademarks, or service marks; or
|
|
||||||
|
|
||||||
f) Requiring indemnification of licensors and authors of that
|
|
||||||
material by anyone who conveys the material (or modified versions of
|
|
||||||
it) with contractual assumptions of liability to the recipient, for
|
|
||||||
any liability that these contractual assumptions directly impose on
|
|
||||||
those licensors and authors.
|
|
||||||
|
|
||||||
All other non-permissive additional terms are considered "further
|
|
||||||
restrictions" within the meaning of section 10. If the Program as you
|
|
||||||
received it, or any part of it, contains a notice stating that it is
|
|
||||||
governed by this License along with a term that is a further
|
|
||||||
restriction, you may remove that term. If a license document contains
|
|
||||||
a further restriction but permits relicensing or conveying under this
|
|
||||||
License, you may add to a covered work material governed by the terms
|
|
||||||
of that license document, provided that the further restriction does
|
|
||||||
not survive such relicensing or conveying.
|
|
||||||
|
|
||||||
If you add terms to a covered work in accord with this section, you
|
|
||||||
must place, in the relevant source files, a statement of the
|
|
||||||
additional terms that apply to those files, or a notice indicating
|
|
||||||
where to find the applicable terms.
|
|
||||||
|
|
||||||
Additional terms, permissive or non-permissive, may be stated in the
|
|
||||||
form of a separately written license, or stated as exceptions;
|
|
||||||
the above requirements apply either way.
|
|
||||||
|
|
||||||
8. Termination.
|
|
||||||
|
|
||||||
You may not propagate or modify a covered work except as expressly
|
|
||||||
provided under this License. Any attempt otherwise to propagate or
|
|
||||||
modify it is void, and will automatically terminate your rights under
|
|
||||||
this License (including any patent licenses granted under the third
|
|
||||||
paragraph of section 11).
|
|
||||||
|
|
||||||
However, if you cease all violation of this License, then your
|
|
||||||
license from a particular copyright holder is reinstated (a)
|
|
||||||
provisionally, unless and until the copyright holder explicitly and
|
|
||||||
finally terminates your license, and (b) permanently, if the copyright
|
|
||||||
holder fails to notify you of the violation by some reasonable means
|
|
||||||
prior to 60 days after the cessation.
|
|
||||||
|
|
||||||
Moreover, your license from a particular copyright holder is
|
|
||||||
reinstated permanently if the copyright holder notifies you of the
|
|
||||||
violation by some reasonable means, this is the first time you have
|
|
||||||
received notice of violation of this License (for any work) from that
|
|
||||||
copyright holder, and you cure the violation prior to 30 days after
|
|
||||||
your receipt of the notice.
|
|
||||||
|
|
||||||
Termination of your rights under this section does not terminate the
|
|
||||||
licenses of parties who have received copies or rights from you under
|
|
||||||
this License. If your rights have been terminated and not permanently
|
|
||||||
reinstated, you do not qualify to receive new licenses for the same
|
|
||||||
material under section 10.
|
|
||||||
|
|
||||||
9. Acceptance Not Required for Having Copies.
|
|
||||||
|
|
||||||
You are not required to accept this License in order to receive or
|
|
||||||
run a copy of the Program. Ancillary propagation of a covered work
|
|
||||||
occurring solely as a consequence of using peer-to-peer transmission
|
|
||||||
to receive a copy likewise does not require acceptance. However,
|
|
||||||
nothing other than this License grants you permission to propagate or
|
|
||||||
modify any covered work. These actions infringe copyright if you do
|
|
||||||
not accept this License. Therefore, by modifying or propagating a
|
|
||||||
covered work, you indicate your acceptance of this License to do so.
|
|
||||||
|
|
||||||
10. Automatic Licensing of Downstream Recipients.
|
|
||||||
|
|
||||||
Each time you convey a covered work, the recipient automatically
|
|
||||||
receives a license from the original licensors, to run, modify and
|
|
||||||
propagate that work, subject to this License. You are not responsible
|
|
||||||
for enforcing compliance by third parties with this License.
|
|
||||||
|
|
||||||
An "entity transaction" is a transaction transferring control of an
|
|
||||||
organization, or substantially all assets of one, or subdividing an
|
|
||||||
organization, or merging organizations. If propagation of a covered
|
|
||||||
work results from an entity transaction, each party to that
|
|
||||||
transaction who receives a copy of the work also receives whatever
|
|
||||||
licenses to the work the party's predecessor in interest had or could
|
|
||||||
give under the previous paragraph, plus a right to possession of the
|
|
||||||
Corresponding Source of the work from the predecessor in interest, if
|
|
||||||
the predecessor has it or can get it with reasonable efforts.
|
|
||||||
|
|
||||||
You may not impose any further restrictions on the exercise of the
|
|
||||||
rights granted or affirmed under this License. For example, you may
|
|
||||||
not impose a license fee, royalty, or other charge for exercise of
|
|
||||||
rights granted under this License, and you may not initiate litigation
|
|
||||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
|
||||||
any patent claim is infringed by making, using, selling, offering for
|
|
||||||
sale, or importing the Program or any portion of it.
|
|
||||||
|
|
||||||
11. Patents.
|
|
||||||
|
|
||||||
A "contributor" is a copyright holder who authorizes use under this
|
|
||||||
License of the Program or a work on which the Program is based. The
|
|
||||||
work thus licensed is called the contributor's "contributor version".
|
|
||||||
|
|
||||||
A contributor's "essential patent claims" are all patent claims
|
|
||||||
owned or controlled by the contributor, whether already acquired or
|
|
||||||
hereafter acquired, that would be infringed by some manner, permitted
|
|
||||||
by this License, of making, using, or selling its contributor version,
|
|
||||||
but do not include claims that would be infringed only as a
|
|
||||||
consequence of further modification of the contributor version. For
|
|
||||||
purposes of this definition, "control" includes the right to grant
|
|
||||||
patent sublicenses in a manner consistent with the requirements of
|
|
||||||
this License.
|
|
||||||
|
|
||||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
|
||||||
patent license under the contributor's essential patent claims, to
|
|
||||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
|
||||||
propagate the contents of its contributor version.
|
|
||||||
|
|
||||||
In the following three paragraphs, a "patent license" is any express
|
|
||||||
agreement or commitment, however denominated, not to enforce a patent
|
|
||||||
(such as an express permission to practice a patent or covenant not to
|
|
||||||
sue for patent infringement). To "grant" such a patent license to a
|
|
||||||
party means to make such an agreement or commitment not to enforce a
|
|
||||||
patent against the party.
|
|
||||||
|
|
||||||
If you convey a covered work, knowingly relying on a patent license,
|
|
||||||
and the Corresponding Source of the work is not available for anyone
|
|
||||||
to copy, free of charge and under the terms of this License, through a
|
|
||||||
publicly available network server or other readily accessible means,
|
|
||||||
then you must either (1) cause the Corresponding Source to be so
|
|
||||||
available, or (2) arrange to deprive yourself of the benefit of the
|
|
||||||
patent license for this particular work, or (3) arrange, in a manner
|
|
||||||
consistent with the requirements of this License, to extend the patent
|
|
||||||
license to downstream recipients. "Knowingly relying" means you have
|
|
||||||
actual knowledge that, but for the patent license, your conveying the
|
|
||||||
covered work in a country, or your recipient's use of the covered work
|
|
||||||
in a country, would infringe one or more identifiable patents in that
|
|
||||||
country that you have reason to believe are valid.
|
|
||||||
|
|
||||||
If, pursuant to or in connection with a single transaction or
|
|
||||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
|
||||||
covered work, and grant a patent license to some of the parties
|
|
||||||
receiving the covered work authorizing them to use, propagate, modify
|
|
||||||
or convey a specific copy of the covered work, then the patent license
|
|
||||||
you grant is automatically extended to all recipients of the covered
|
|
||||||
work and works based on it.
|
|
||||||
|
|
||||||
A patent license is "discriminatory" if it does not include within
|
|
||||||
the scope of its coverage, prohibits the exercise of, or is
|
|
||||||
conditioned on the non-exercise of one or more of the rights that are
|
|
||||||
specifically granted under this License. You may not convey a covered
|
|
||||||
work if you are a party to an arrangement with a third party that is
|
|
||||||
in the business of distributing software, under which you make payment
|
|
||||||
to the third party based on the extent of your activity of conveying
|
|
||||||
the work, and under which the third party grants, to any of the
|
|
||||||
parties who would receive the covered work from you, a discriminatory
|
|
||||||
patent license (a) in connection with copies of the covered work
|
|
||||||
conveyed by you (or copies made from those copies), or (b) primarily
|
|
||||||
for and in connection with specific products or compilations that
|
|
||||||
contain the covered work, unless you entered into that arrangement,
|
|
||||||
or that patent license was granted, prior to 28 March 2007.
|
|
||||||
|
|
||||||
Nothing in this License shall be construed as excluding or limiting
|
|
||||||
any implied license or other defenses to infringement that may
|
|
||||||
otherwise be available to you under applicable patent law.
|
|
||||||
|
|
||||||
12. No Surrender of Others' Freedom.
|
|
||||||
|
|
||||||
If conditions are imposed on you (whether by court order, agreement or
|
|
||||||
otherwise) that contradict the conditions of this License, they do not
|
|
||||||
excuse you from the conditions of this License. If you cannot convey a
|
|
||||||
covered work so as to satisfy simultaneously your obligations under this
|
|
||||||
License and any other pertinent obligations, then as a consequence you may
|
|
||||||
not convey it at all. For example, if you agree to terms that obligate you
|
|
||||||
to collect a royalty for further conveying from those to whom you convey
|
|
||||||
the Program, the only way you could satisfy both those terms and this
|
|
||||||
License would be to refrain entirely from conveying the Program.
|
|
||||||
|
|
||||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, if you modify the
|
|
||||||
Program, your modified version must prominently offer all users
|
|
||||||
interacting with it remotely through a computer network (if your version
|
|
||||||
supports such interaction) an opportunity to receive the Corresponding
|
|
||||||
Source of your version by providing access to the Corresponding Source
|
|
||||||
from a network server at no charge, through some standard or customary
|
|
||||||
means of facilitating copying of software. This Corresponding Source
|
|
||||||
shall include the Corresponding Source for any work covered by version 3
|
|
||||||
of the GNU General Public License that is incorporated pursuant to the
|
|
||||||
following paragraph.
|
|
||||||
|
|
||||||
Notwithstanding any other provision of this License, you have
|
|
||||||
permission to link or combine any covered work with a work licensed
|
|
||||||
under version 3 of the GNU General Public License into a single
|
|
||||||
combined work, and to convey the resulting work. The terms of this
|
|
||||||
License will continue to apply to the part which is the covered work,
|
|
||||||
but the work with which it is combined will remain governed by version
|
|
||||||
3 of the GNU General Public License.
|
|
||||||
|
|
||||||
14. Revised Versions of this License.
|
|
||||||
|
|
||||||
The Free Software Foundation may publish revised and/or new versions of
|
|
||||||
the GNU Affero General Public License from time to time. Such new versions
|
|
||||||
will be similar in spirit to the present version, but may differ in detail to
|
|
||||||
address new problems or concerns.
|
|
||||||
|
|
||||||
Each version is given a distinguishing version number. If the
|
|
||||||
Program specifies that a certain numbered version of the GNU Affero General
|
|
||||||
Public License "or any later version" applies to it, you have the
|
|
||||||
option of following the terms and conditions either of that numbered
|
|
||||||
version or of any later version published by the Free Software
|
|
||||||
Foundation. If the Program does not specify a version number of the
|
|
||||||
GNU Affero General Public License, you may choose any version ever published
|
|
||||||
by the Free Software Foundation.
|
|
||||||
|
|
||||||
If the Program specifies that a proxy can decide which future
|
|
||||||
versions of the GNU Affero General Public License can be used, that proxy's
|
|
||||||
public statement of acceptance of a version permanently authorizes you
|
|
||||||
to choose that version for the Program.
|
|
||||||
|
|
||||||
Later license versions may give you additional or different
|
|
||||||
permissions. However, no additional obligations are imposed on any
|
|
||||||
author or copyright holder as a result of your choosing to follow a
|
|
||||||
later version.
|
|
||||||
|
|
||||||
15. Disclaimer of Warranty.
|
|
||||||
|
|
||||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
|
||||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
|
||||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
|
||||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
|
||||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
|
||||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
|
||||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
|
||||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
|
||||||
|
|
||||||
16. Limitation of Liability.
|
|
||||||
|
|
||||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
|
||||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
|
||||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
|
||||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
|
||||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
|
||||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
|
||||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
|
||||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
|
||||||
SUCH DAMAGES.
|
|
||||||
|
|
||||||
17. Interpretation of Sections 15 and 16.
|
|
||||||
|
|
||||||
If the disclaimer of warranty and limitation of liability provided
|
|
||||||
above cannot be given local legal effect according to their terms,
|
|
||||||
reviewing courts shall apply local law that most closely approximates
|
|
||||||
an absolute waiver of all civil liability in connection with the
|
|
||||||
Program, unless a warranty or assumption of liability accompanies a
|
|
||||||
copy of the Program in return for a fee.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
How to Apply These Terms to Your New Programs
|
|
||||||
|
|
||||||
If you develop a new program, and you want it to be of the greatest
|
|
||||||
possible use to the public, the best way to achieve this is to make it
|
|
||||||
free software which everyone can redistribute and change under these terms.
|
|
||||||
|
|
||||||
To do so, attach the following notices to the program. It is safest
|
|
||||||
to attach them to the start of each source file to most effectively
|
|
||||||
state the exclusion of warranty; and each file should have at least
|
|
||||||
the "copyright" line and a pointer to where the full notice is found.
|
|
||||||
|
|
||||||
<one line to give the program's name and a brief idea of what it does.>
|
|
||||||
Copyright (C) <year> <name of author>
|
|
||||||
|
|
||||||
This program is free software: you can redistribute it and/or modify
|
|
||||||
it under the terms of the GNU Affero General Public License as published by
|
|
||||||
the Free Software Foundation, either version 3 of the License, or
|
|
||||||
(at your option) any later version.
|
|
||||||
|
|
||||||
This program is distributed in the hope that it will be useful,
|
|
||||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
GNU Affero General Public License for more details.
|
|
||||||
|
|
||||||
You should have received a copy of the GNU Affero General Public License
|
|
||||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
Also add information on how to contact you by electronic and paper mail.
|
|
||||||
|
|
||||||
If your software can interact with users remotely through a computer
|
|
||||||
network, you should also make sure that it provides a way for users to
|
|
||||||
get its source. For example, if your program is a web application, its
|
|
||||||
interface could display a "Source" link that leads users to an archive
|
|
||||||
of the code. There are many ways you could offer source, and different
|
|
||||||
solutions will be better for different programs; see section 13 for the
|
|
||||||
specific requirements.
|
|
||||||
|
|
||||||
You should also get your employer (if you work as a programmer) or school,
|
|
||||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
|
||||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
|
||||||
<https://www.gnu.org/licenses/>.
|
|
||||||
Vendored
-101
@@ -1,101 +0,0 @@
|
|||||||
# Nextcloud App Store Release Makefile for Astrolabe
|
|
||||||
#
|
|
||||||
# Based on: https://nextcloudappstore.readthedocs.io/en/latest/developer.html
|
|
||||||
|
|
||||||
app_name=astrolabe
|
|
||||||
project_dir=$(CURDIR)
|
|
||||||
build_dir=$(project_dir)/build
|
|
||||||
appstore_dir=$(build_dir)/artifacts
|
|
||||||
package_name=$(appstore_dir)/$(app_name)
|
|
||||||
cert_dir=$(HOME)/.nextcloud/certificates
|
|
||||||
|
|
||||||
# Nextcloud server path (configurable via environment variable)
|
|
||||||
server_dir?=../../server
|
|
||||||
occ=$(server_dir)/occ
|
|
||||||
|
|
||||||
# Signing
|
|
||||||
private_key=$(cert_dir)/$(app_name).key
|
|
||||||
certificate=$(cert_dir)/$(app_name).crt
|
|
||||||
sign_cmd=php $(occ) integrity:sign-app --privateKey=$(private_key) --certificate=$(certificate)
|
|
||||||
|
|
||||||
# Clean build artifacts
|
|
||||||
.PHONY: clean
|
|
||||||
clean:
|
|
||||||
rm -rf $(build_dir)
|
|
||||||
|
|
||||||
# Validate required dependencies
|
|
||||||
.PHONY: validate-deps
|
|
||||||
validate-deps:
|
|
||||||
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Install from https://getcomposer.org/"; exit 1; }
|
|
||||||
@command -v npm >/dev/null 2>&1 || { echo "Error: npm not found. Install Node.js from https://nodejs.org/"; exit 1; }
|
|
||||||
@command -v php >/dev/null 2>&1 || { echo "Error: php not found. Install PHP 8.1 or higher."; exit 1; }
|
|
||||||
@echo "✓ All dependencies found"
|
|
||||||
|
|
||||||
# Install PHP and Node dependencies
|
|
||||||
.PHONY: install-deps
|
|
||||||
install-deps: validate-deps
|
|
||||||
composer install --no-dev --optimize-autoloader
|
|
||||||
npm ci
|
|
||||||
|
|
||||||
# Build production frontend assets
|
|
||||||
.PHONY: build-frontend
|
|
||||||
build-frontend:
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Run all linters
|
|
||||||
.PHONY: lint
|
|
||||||
lint:
|
|
||||||
composer lint
|
|
||||||
composer cs:check
|
|
||||||
npm run lint
|
|
||||||
npm run stylelint
|
|
||||||
|
|
||||||
# Assemble app files into build directory (exclude dev files)
|
|
||||||
.PHONY: assemble
|
|
||||||
assemble: clean install-deps build-frontend
|
|
||||||
mkdir -p $(package_name)
|
|
||||||
# Copy app files
|
|
||||||
rsync -av \
|
|
||||||
--exclude='.git*' \
|
|
||||||
--exclude='build/' \
|
|
||||||
--exclude='tests/' \
|
|
||||||
--exclude='node_modules/' \
|
|
||||||
--exclude='*.log' \
|
|
||||||
--exclude='.github/' \
|
|
||||||
--exclude='composer.json' \
|
|
||||||
--exclude='composer.lock' \
|
|
||||||
--exclude='package.json' \
|
|
||||||
--exclude='package-lock.json' \
|
|
||||||
--exclude='vite.config.js' \
|
|
||||||
--exclude='.eslintrc.js' \
|
|
||||||
--exclude='.php-cs-fixer.*' \
|
|
||||||
--exclude='psalm.xml' \
|
|
||||||
--exclude='*.iml' \
|
|
||||||
--exclude='.idea' \
|
|
||||||
--exclude='src/' \
|
|
||||||
./ $(package_name)/
|
|
||||||
|
|
||||||
# Validate signing prerequisites
|
|
||||||
.PHONY: validate-signing
|
|
||||||
validate-signing:
|
|
||||||
@test -f $(occ) || { echo "Error: Nextcloud server not found at $(server_dir)"; echo "Set server_dir variable: make appstore server_dir=/path/to/server"; exit 1; }
|
|
||||||
@test -f $(private_key) || { echo "Error: Private key not found at $(private_key)"; exit 1; }
|
|
||||||
@test -f $(certificate) || { echo "Error: Certificate not found at $(certificate)"; exit 1; }
|
|
||||||
@echo "✓ Signing prerequisites validated"
|
|
||||||
|
|
||||||
# Create signed release tarball for App Store
|
|
||||||
.PHONY: appstore
|
|
||||||
appstore: assemble validate-signing
|
|
||||||
# Sign the app
|
|
||||||
$(sign_cmd) --path=$(package_name)
|
|
||||||
# Create tarball
|
|
||||||
cd $(appstore_dir) && \
|
|
||||||
tar -czf $(app_name).tar.gz $(app_name)
|
|
||||||
# Show package info
|
|
||||||
@echo "========================================="
|
|
||||||
@echo "App package created:"
|
|
||||||
@echo " $(appstore_dir)/$(app_name).tar.gz"
|
|
||||||
@echo ""
|
|
||||||
@echo "Signature:"
|
|
||||||
@cat $(package_name)/appinfo/signature.json | head -n 5
|
|
||||||
@echo "========================================="
|
|
||||||
Vendored
-223
@@ -1,223 +0,0 @@
|
|||||||
# Astrolabe: The Intelligence Layer for Nextcloud
|
|
||||||
|
|
||||||
Your Nextcloud instance is more than just a bucket for files—it is a galaxy of ideas, projects, and knowledge. But until now, you've been navigating it in the dark, relying on exact filenames and rigid keywords.
|
|
||||||
|
|
||||||
**It's time to turn the lights on.**
|
|
||||||
|
|
||||||
Astrolabe is a fully integrated Nextcloud application that transforms your server into a semantic intelligence engine. It doesn't just store your data; it **maps it, understands it, and connects it** to the AI future.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What You Can Do
|
|
||||||
|
|
||||||
### 🔍 Search That Actually Understands
|
|
||||||
|
|
||||||
Forget clunky external tools. Astrolabe registers as a **native Nextcloud Search Provider**.
|
|
||||||
|
|
||||||
- **Seamless**: Lives right in the standard Nextcloud search bar you already use
|
|
||||||
- **Semantic**: Type "marketing strategy for the winter launch" and Astrolabe finds the relevant PDFs, chat logs, and text files—even if those exact words never appear in the document
|
|
||||||
- **Intelligent**: It finds the **concept**, not just the string
|
|
||||||
|
|
||||||
### 🌌 Visualize Your Data Universe
|
|
||||||
|
|
||||||
Data shouldn't just be a list; it should be a landscape. Astrolabe includes a dedicated dashboard that visualizes your document chunks as a **3D PCA Vector Plot**.
|
|
||||||
|
|
||||||
- **See the Connections**: View your data as a constellation of points in 3D space
|
|
||||||
- **Explore Clusters**: Visually identify how your documents relate to one another
|
|
||||||
- **True "Astroglobe" Experience**: Rotate, zoom, and fly through your semantic universe just like navigators once studied the stars
|
|
||||||
|
|
||||||
### 🤖 Power Your AI Agents
|
|
||||||
|
|
||||||
Astrolabe isn't just for humans; it's for your AI agents, too. It acts as a bridge, running a **Model Context Protocol (MCP) Server** directly from your Nextcloud.
|
|
||||||
|
|
||||||
- **Bring Your Own Brain**: Connect external AI clients (like Claude Desktop or Cursor) to your private data
|
|
||||||
- **Agentic Workflows**: Enable LLMs to "sample" your files, read content, and perform complex reasoning tasks using your Nextcloud data as the source of truth
|
|
||||||
- **Private & Secure**: Your data never leaves your infrastructure
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
### From App Store (Recommended)
|
|
||||||
|
|
||||||
1. Open **Apps** in your Nextcloud
|
|
||||||
2. Search for **"Astrolabe"**
|
|
||||||
3. Click **"Download and enable"**
|
|
||||||
|
|
||||||
### Manual Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Clone into your Nextcloud apps directory
|
|
||||||
cd /path/to/nextcloud/apps
|
|
||||||
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
|
|
||||||
cd nextcloud-mcp-server/third_party/astrolabe
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
composer install
|
|
||||||
|
|
||||||
# Enable the app
|
|
||||||
php /path/to/nextcloud/occ app:enable astrolabe
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### 1. Configure the MCP Server URL
|
|
||||||
|
|
||||||
Add this to your Nextcloud `config/config.php`:
|
|
||||||
|
|
||||||
```php
|
|
||||||
'mcp_server_url' => 'http://localhost:8000',
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Start the MCP Server
|
|
||||||
|
|
||||||
The MCP server handles semantic search and AI agent connections. See the [MCP Server Installation Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md) for details.
|
|
||||||
|
|
||||||
Quick start with Docker:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
|
||||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Authorize Access
|
|
||||||
|
|
||||||
1. Go to **Settings → Personal → Astrolabe**
|
|
||||||
2. Click **"Authorize Access"**
|
|
||||||
3. Sign in to your identity provider
|
|
||||||
4. Approve the requested permissions
|
|
||||||
|
|
||||||
That's it! You can now use semantic search and explore your data universe.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
### Personal Settings
|
|
||||||
|
|
||||||
Located in: **Settings → Personal → Astrolabe**
|
|
||||||
|
|
||||||
- **Semantic Search Dashboard**: Interactive 3D visualization of your document chunks
|
|
||||||
- **OAuth Authorization**: Authorize Nextcloud to access the MCP server on your behalf
|
|
||||||
- **Session Information**: View connection status and authentication details
|
|
||||||
- **Connection Management**: Revoke access or disconnect when needed
|
|
||||||
|
|
||||||
### Admin Settings
|
|
||||||
|
|
||||||
Located in: **Settings → Administration → Astrolabe**
|
|
||||||
|
|
||||||
- **Server Status**: Monitor MCP server health and version
|
|
||||||
- **Vector Sync Metrics**: See how many documents are indexed, processing rates, and sync status
|
|
||||||
- **Configuration Validation**: Verify server URL and connectivity
|
|
||||||
- **Feature Availability**: Check which capabilities are enabled
|
|
||||||
|
|
||||||
### Unified Search Integration
|
|
||||||
|
|
||||||
Astrolabe integrates directly with Nextcloud's **Unified Search**:
|
|
||||||
|
|
||||||
- Available in the top search bar across all Nextcloud pages
|
|
||||||
- Returns semantic matches ranked by relevance
|
|
||||||
- Shows excerpts from matching documents
|
|
||||||
- Links directly to source files in Nextcloud
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Use Cases
|
|
||||||
|
|
||||||
### For Individuals
|
|
||||||
|
|
||||||
- **Research**: Find all notes related to a project, even if they use different terminology
|
|
||||||
- **Organization**: Discover forgotten documents related to your current work
|
|
||||||
- **Exploration**: Visualize how your knowledge connects and evolves over time
|
|
||||||
|
|
||||||
### For Teams
|
|
||||||
|
|
||||||
- **Knowledge Discovery**: Surface institutional knowledge that would otherwise stay buried
|
|
||||||
- **Collaboration**: Find team members working on similar problems
|
|
||||||
- **Documentation**: Locate relevant documentation without knowing exact titles
|
|
||||||
|
|
||||||
### For Developers
|
|
||||||
|
|
||||||
- **AI Integration**: Connect Claude Desktop, Cursor, or other MCP clients to Nextcloud
|
|
||||||
- **RAG Workflows**: Build retrieval-augmented generation pipelines on your private data
|
|
||||||
- **Custom Agents**: Use the MCP protocol to create specialized workflows
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- **Nextcloud**: Version 30 or later
|
|
||||||
- **MCP Server**: Running instance (Docker recommended)
|
|
||||||
- **Identity Provider**: OAuth provider supporting PKCE (Nextcloud OIDC Login or Keycloak)
|
|
||||||
- **Vector Sync**: Optional but recommended for semantic search (see [configuration guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md))
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
### User Guides
|
|
||||||
|
|
||||||
- [MCP Server Installation](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md)
|
|
||||||
- [Configuration Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md)
|
|
||||||
- [OAuth Setup](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/oauth-setup.md)
|
|
||||||
|
|
||||||
### Technical Details
|
|
||||||
|
|
||||||
- [ADR-018: Nextcloud PHP App Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-018-nextcloud-php-app-for-settings-ui.md)
|
|
||||||
- [OAuth PKCE Flow Details](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-004-progressive-consent.md)
|
|
||||||
- [Vector Sync Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-002-vector-sync-authentication.md)
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
|
|
||||||
**Cannot connect to MCP server:**
|
|
||||||
- Verify `mcp_server_url` in `config.php`
|
|
||||||
- Check MCP server is running: `curl http://localhost:8000/health`
|
|
||||||
- Review logs: `tail -f data/nextcloud.log`
|
|
||||||
|
|
||||||
**Authorization fails:**
|
|
||||||
- Ensure MCP server is in OAuth mode
|
|
||||||
- Verify identity provider is accessible
|
|
||||||
- Check browser console for errors
|
|
||||||
|
|
||||||
**Semantic search returns no results:**
|
|
||||||
- Verify vector sync is enabled and running
|
|
||||||
- Check indexing status in Admin settings
|
|
||||||
- Allow time for initial indexing to complete
|
|
||||||
|
|
||||||
For more help, see the [Troubleshooting Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/troubleshooting.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions! Here's how to get started:
|
|
||||||
|
|
||||||
1. Fork the [nextcloud-mcp-server repository](https://github.com/cbcoutinho/nextcloud-mcp-server)
|
|
||||||
2. Create a feature branch: `git checkout -b feature/your-feature`
|
|
||||||
3. Make your changes in `third_party/astrolabe/`
|
|
||||||
4. Test thoroughly with a local Nextcloud instance
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
See [CONTRIBUTING.md](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CONTRIBUTING.md) for detailed guidelines.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
AGPL-3.0
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## About
|
|
||||||
|
|
||||||
**Astrolabe** is developed as part of the [Nextcloud MCP Server](https://github.com/cbcoutinho/nextcloud-mcp-server) project, bringing the power of semantic search and AI integration to Nextcloud.
|
|
||||||
|
|
||||||
**Author**: Chris Coutinho <chris@coutinho.io>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Your Data. Mapped. Visualized. Connected.**
|
|
||||||
|
|
||||||
Install Astrolabe for Nextcloud.
|
|
||||||
-63
@@ -1,63 +0,0 @@
|
|||||||
<?xml version="1.0"?>
|
|
||||||
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
|
|
||||||
<id>astrolabe</id>
|
|
||||||
<name>Astrolabe</name>
|
|
||||||
<summary>AI-powered semantic search across your Nextcloud</summary>
|
|
||||||
<description>< for configuration details.
|
|
||||||
]]></description>
|
|
||||||
<version>0.9.0</version>
|
|
||||||
<licence>agpl</licence>
|
|
||||||
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
|
||||||
<namespace>Astrolabe</namespace>
|
|
||||||
<category>ai</category>
|
|
||||||
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
|
|
||||||
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
|
|
||||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/02-semantic-search-with-plot.png?raw=1</screenshot>
|
|
||||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
|
|
||||||
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
|
|
||||||
<dependencies>
|
|
||||||
<nextcloud min-version="31" max-version="32"/>
|
|
||||||
</dependencies>
|
|
||||||
<settings>
|
|
||||||
<personal>OCA\Astrolabe\Settings\Personal</personal>
|
|
||||||
<personal-section>OCA\Astrolabe\Settings\PersonalSection</personal-section>
|
|
||||||
<admin>OCA\Astrolabe\Settings\Admin</admin>
|
|
||||||
<admin-section>OCA\Astrolabe\Settings\AdminSection</admin-section>
|
|
||||||
</settings>
|
|
||||||
<navigations>
|
|
||||||
<navigation>
|
|
||||||
<id>astrolabe</id>
|
|
||||||
<name>Astrolabe</name>
|
|
||||||
<route>astrolabe.page.index</route>
|
|
||||||
<icon>app.svg</icon>
|
|
||||||
<type>link</type>
|
|
||||||
</navigation>
|
|
||||||
</navigations>
|
|
||||||
<background-jobs>
|
|
||||||
<job>OCA\Astrolabe\BackgroundJob\RefreshUserTokens</job>
|
|
||||||
</background-jobs>
|
|
||||||
</info>
|
|
||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Routes configuration for MCP Server UI app.
|
|
||||||
*
|
|
||||||
* Defines URL routes for OAuth flow and form handlers.
|
|
||||||
*/
|
|
||||||
|
|
||||||
return [
|
|
||||||
'routes' => [
|
|
||||||
// OAuth routes
|
|
||||||
[
|
|
||||||
'name' => 'oauth#initiateOAuth',
|
|
||||||
'url' => '/oauth/authorize',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'oauth#oauthCallback',
|
|
||||||
'url' => '/oauth/callback',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'oauth#disconnect',
|
|
||||||
'url' => '/oauth/disconnect',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
|
|
||||||
// API routes (form handlers)
|
|
||||||
[
|
|
||||||
'name' => 'api#revokeAccess',
|
|
||||||
'url' => '/api/revoke',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Background sync credentials routes
|
|
||||||
[
|
|
||||||
'name' => 'credentials#storeAppPassword',
|
|
||||||
'url' => '/api/v1/background-sync/credentials',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'credentials#getCredentials',
|
|
||||||
'url' => '/api/v1/background-sync/credentials/{userId}',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'credentials#deleteCredentials',
|
|
||||||
'url' => '/api/v1/background-sync/credentials/revoke',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'credentials#getStatus',
|
|
||||||
'url' => '/api/v1/background-sync/status',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Vector search API routes
|
|
||||||
[
|
|
||||||
'name' => 'api#search',
|
|
||||||
'url' => '/api/search',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#vectorStatus',
|
|
||||||
'url' => '/api/vector-status',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#chunkContext',
|
|
||||||
'url' => '/api/chunk-context',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#pdfPreview',
|
|
||||||
'url' => '/api/pdf-preview',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Admin settings routes
|
|
||||||
[
|
|
||||||
'name' => 'api#serverStatus',
|
|
||||||
'url' => '/api/admin/server-status',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#adminVectorStatus',
|
|
||||||
'url' => '/api/admin/vector-status',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#saveSearchSettings',
|
|
||||||
'url' => '/api/admin/search-settings',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
|
|
||||||
// Webhook management routes (admin only)
|
|
||||||
[
|
|
||||||
'name' => 'api#getWebhookPresets',
|
|
||||||
'url' => '/api/admin/webhooks/presets',
|
|
||||||
'verb' => 'GET',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#enableWebhookPreset',
|
|
||||||
'url' => '/api/admin/webhooks/presets/{presetId}/enable',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'name' => 'api#disableWebhookPreset',
|
|
||||||
'url' => '/api/admin/webhooks/presets/{presetId}/disable',
|
|
||||||
'verb' => 'POST',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
Vendored
-57
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "nextcloud/astrolabe",
|
|
||||||
"description": "This app provides a management UI for the Nextcloud MCP Server",
|
|
||||||
"license": "AGPL-3.0-or-later",
|
|
||||||
"authors": [
|
|
||||||
{
|
|
||||||
"name": "Chris Coutinho",
|
|
||||||
"email": "chris@coutinho.io",
|
|
||||||
"homepage": "https://github.com/cbcoutinho"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"autoload": {
|
|
||||||
"psr-4": {
|
|
||||||
"OCA\\Astrolabe\\": "lib/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"OCP\\": "vendor/nextcloud/ocp/OCP/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"post-install-cmd": [
|
|
||||||
"@composer bin all install --ansi"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@composer bin all install --ansi"
|
|
||||||
],
|
|
||||||
"lint": "find . -name \\*.php -not -path './vendor/*' -not -path './vendor-bin/*' -not -path './build/*' -print0 | xargs -0 -n1 php -l",
|
|
||||||
"cs:check": "php-cs-fixer fix --dry-run --diff",
|
|
||||||
"cs:fix": "php-cs-fixer fix",
|
|
||||||
"psalm": "psalm --threads=1 --no-cache",
|
|
||||||
"test:unit": "./vendor/bin/phpunit -c tests/unit/phpunit.xml --colors=always",
|
|
||||||
"openapi": "generate-spec",
|
|
||||||
"rector": "rector && composer cs:fix"
|
|
||||||
},
|
|
||||||
"require": {
|
|
||||||
"bamarni/composer-bin-plugin": "^1.8",
|
|
||||||
"php": "^8.1"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"doctrine/dbal": "^3.8",
|
|
||||||
"nextcloud/ocp": "dev-stable30",
|
|
||||||
"phpunit/phpunit": "^10.0",
|
|
||||||
"roave/security-advisories": "dev-latest"
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"allow-plugins": {
|
|
||||||
"bamarni/composer-bin-plugin": true
|
|
||||||
},
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"sort-packages": true,
|
|
||||||
"platform": {
|
|
||||||
"php": "8.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user