Compare commits
308 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e2e0ffce44 | |||
| 2f3a3e0be4 | |||
| c5f7221fb2 | |||
| 4a42b947bc | |||
| 46b260641f | |||
| 60d80970a4 | |||
| daabd90359 | |||
| fe54733a39 | |||
| 8d6eff2792 | |||
| e4f3beee01 | |||
| 54b69f0d68 | |||
| c4b3df04a0 | |||
| d4c0da85da | |||
| 3fa376905c | |||
| a4a34e46a8 | |||
| d235dfa023 | |||
| 24898439cb | |||
| 6da98b4e7b | |||
| fba4b9b785 | |||
| b246a03ac4 | |||
| 04c64e97b0 | |||
| af9a55cebd | |||
| 44391d3d1d | |||
| 619c62d89a | |||
| dfc81923ba | |||
| 5a6205476a | |||
| be7f512244 | |||
| 5eec34c17e | |||
| 656214b162 | |||
| 45fc25d02b | |||
| 9aec5582db | |||
| 0f7e87a91c | |||
| 5acac804a1 | |||
| 85db90a2df | |||
| a026f2eddb | |||
| 73783b85d5 | |||
| 4cce4f6392 | |||
| 24e63a967a | |||
| dbb6ba333a | |||
| 97b48ca3dd | |||
| a4106ee20d | |||
| 21817543ad | |||
| 6babbc99e7 | |||
| 1f5e9d815b | |||
| 83caa48cdb | |||
| b51019a7e8 | |||
| 72d65cd7ae | |||
| 76251e935e | |||
| a58a14111b | |||
| 49230c3a44 | |||
| 262d2b2133 | |||
| ad2ff2ccc4 | |||
| dff7a58736 | |||
| 44c9bd645e | |||
| 4741d60e4c | |||
| 1a079a41e7 | |||
| ebbd3bcc61 | |||
| 54fdc8addc | |||
| e0320e761c | |||
| 2b7c308188 | |||
| 40ac52654f | |||
| 034e405824 | |||
| 20404cf3f2 | |||
| 264bb5475c | |||
| 6e3f9f6e79 | |||
| 9d0a993c2a | |||
| cd3e60ba4f | |||
| 360299f5f6 | |||
| d61e33113c | |||
| 5faf7cf45f | |||
| cd922fa750 | |||
| a4d4c386f7 | |||
| c8da826ef7 | |||
| 5166c2c4d7 | |||
| ec70e70a5d | |||
| 4a79b37714 | |||
| 76ae1c3603 | |||
| a60b88b80e | |||
| e31b4433a1 | |||
| 19183ad14a | |||
| e1412320a7 | |||
| b9c94dfab0 | |||
| 6f43c09bd0 | |||
| 9e15e95c2b | |||
| 1306c4cc9c | |||
| f1247817d3 | |||
| fdad5b85c9 | |||
| 39ee0b5973 | |||
| 33675c8ae8 | |||
| 90d5e9887a | |||
| c3af591810 | |||
| bb8a6200aa | |||
| 44573366eb | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 0b58707a49 | |||
| 0561b55af5 | |||
| d785ed9054 | |||
| 88fb8417fd | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| e28dcbff9a | |||
| 89ec0186a4 | |||
| 6e1efde8c6 | |||
| 6aa80d4210 | |||
| 4e86006b3f | |||
| 679e22a7c2 | |||
| 4d3228a4a8 | |||
| 0aa307f0b6 | |||
| 6a69ecefb1 | |||
| c05beb66e9 | |||
| 34ddb24014 | |||
| 9d69613df7 | |||
| 630f818538 | |||
| b280a720ff | |||
| 48bac9c212 | |||
| e88c49fb50 | |||
| 9e10a5a400 | |||
| 1dbea24fa2 | |||
| 0606228b40 | |||
| f35b9f0988 | |||
| c400c46672 | |||
| fbdeb2161d | |||
| 8c7d03dd29 | |||
| 135ce7b2df | |||
| 0e47ae051b | |||
| 04255473d2 | |||
| ce6bbff389 | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 | |||
| d79e9090e6 | |||
| 97fd660e38 | |||
| 96e168d035 | |||
| 4d2b77ecaf | |||
| e48da80a4b | |||
| 6125312f61 | |||
| 007fd0c2e3 | |||
| c4f90d6a57 | |||
| 5dd62c9466 | |||
| 4d072d7217 | |||
| b4242b1394 | |||
| fa2343dff9 | |||
| 1b1667bc2b | |||
| c2b4bf9c67 | |||
| 0845fefe6c | |||
| d911556a84 | |||
| 38be8d9401 | |||
| 9f3190f62a | |||
| 41aeb7e0f2 | |||
| f8e67519e1 | |||
| 4279dcba1e | |||
| be7e3d6b56 | |||
| 41e128190b | |||
| ba869ccde5 | |||
| 27fe066b23 | |||
| e94b8ff714 | |||
| e3a6894904 | |||
| 92b97bda00 | |||
| d5c6039296 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 86d13a7240 | |||
| 0b2d449ffa | |||
| d881373dce | |||
| 9ade4c65f3 | |||
| 5c73b85f65 | |||
| f5764c01fc | |||
| 8c7c2a4407 | |||
| 978de5e9a4 | |||
| 4e9859117c | |||
| a134a0fc08 | |||
| 6df58af0c3 | |||
| 852606ec8b | |||
| caae6922be | |||
| fafeaf3d83 | |||
| 2ab8dad6a5 | |||
| 50216accde | |||
| bf2fdac2d0 | |||
| 626c4bf562 | |||
| a56b3f3d51 | |||
| 2896fa1dc9 | |||
| 04251401aa | |||
| e86b6e83ae | |||
| 6f5e75da15 | |||
| b2742aab80 | |||
| 208365cd3d | |||
| 26f679d86e | |||
| cf39a15db1 | |||
| 1f3c35f162 | |||
| 2bccc3dad9 | |||
| 959cb8b21a | |||
| f8a2410a0a | |||
| 03b984d5a7 | |||
| 57db18c6a3 | |||
| ea79e94842 | |||
| b0612cfa0f | |||
| 4e61d73da5 | |||
| 3b41776110 | |||
| 3e3d38696c | |||
| 7b22e5be0f | |||
| 39fba49cfe | |||
| 706a15f0bc | |||
| b8dc413b73 | |||
| 8d29ce0122 | |||
| a272e7cbab | |||
| ce55b239e2 | |||
| 432ab73741 | |||
| f93d650992 | |||
| f9da19d1a1 | |||
| d2b6a26fe4 | |||
| 482ef89a73 | |||
| 34fd17ba55 | |||
| 8baa07db84 | |||
| ba8a53803a | |||
| 31fade9730 | |||
| fffe483c02 | |||
| 8c79993280 | |||
| 8a0672a6be | |||
| 395f798ee2 | |||
| debff75221 | |||
| 4bf0a6c22e | |||
| fb025821cb | |||
| ff880fd4c9 | |||
| 03495d901d | |||
| 798958f20a | |||
| 699295c5be | |||
| a62a007c87 | |||
| d4fc1de80d | |||
| 0902b5653f | |||
| 0b6a02075c | |||
| 7880a8de30 | |||
| 2abedd6b4b | |||
| 5a251a99e6 | |||
| 25ef33de7f | |||
| ec2c274cd9 | |||
| 47f0b3db9a | |||
| 233de3508f | |||
| 13b2d0048c | |||
| 944dd760ca | |||
| d67aa6ae5c | |||
| f1a5fac1b9 | |||
| d0691d5aa0 | |||
| f1610bbd2e | |||
| 327d843f64 | |||
| b8010270c1 | |||
| 0f24bdb17a | |||
| bf11f16e2f | |||
| bf05ff8d6e | |||
| c4ce28f05d | |||
| 9b2a06964b | |||
| c126c3ec03 | |||
| 9bd02d7ef7 | |||
| e38a830f02 | |||
| 18b753c3c7 | |||
| b0735bae85 | |||
| 53689d076b | |||
| 0f7d6c0e33 | |||
| 16701fdb72 | |||
| 9db20a4d01 | |||
| 7ddf8370e6 | |||
| 98dff98e9c | |||
| 73e8012707 | |||
| c2fd87a5d3 | |||
| 441d94301e | |||
| b488d69939 | |||
| eec923eff5 | |||
| 3642faf32c | |||
| 3b1cd96722 | |||
| 219d064459 | |||
| d0ab8d071a | |||
| b792e9d9a3 | |||
| 4288814ff4 | |||
| f34a1c5677 | |||
| 6d48f90112 | |||
| b72aeca55f | |||
| c1ae818b75 | |||
| ebca2bfc70 | |||
| 6dcd0bae48 | |||
| 818f643dca | |||
| d31b490f13 | |||
| 839cf159b8 | |||
| cefb438017 | |||
| efc78a835e | |||
| fa25a1b4df | |||
| 8367208a03 | |||
| 52acc4bc07 | |||
| d374bfa1e5 | |||
| b1f7b1d30b | |||
| b8bdbb499f | |||
| 2522b13d35 | |||
| 6cfd7e2729 | |||
| 3aa7128f45 | |||
| c3282534eb | |||
| 862308418e | |||
| 3464b21845 | |||
| ea01ce7673 | |||
| 216cb94383 | |||
| 5f3e0b84a3 | |||
| 39131cefcc | |||
| 9498c0fa36 | |||
| 392e1536b9 | |||
| 00ed3f07e5 |
@@ -5,3 +5,5 @@
|
||||
!uv.lock
|
||||
|
||||
!nextcloud_mcp_server/**/*.py
|
||||
!nextcloud_mcp_server/**/*.html
|
||||
!nextcloud_mcp_server/auth/static/*
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
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
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@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@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.1
|
||||
coverage: none
|
||||
|
||||
- name: Checkout Nextcloud server (for signing)
|
||||
uses: actions/checkout@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@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@v1.0.6
|
||||
with:
|
||||
app_name: ${{ env.APP_NAME }}
|
||||
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||
@@ -0,0 +1,275 @@
|
||||
# Consolidated CI workflow for Astroglobe Nextcloud app
|
||||
#
|
||||
# Runs on PRs that modify the astroglobe directory
|
||||
# Based on Nextcloud app skeleton workflows
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2025 Nextcloud MCP Server contributors
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
name: Astroglobe CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'third_party/astroglobe/**'
|
||||
- '.github/workflows/astroglobe-ci.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: astroglobe-ci-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
frontend: ${{ steps.changes.outputs.frontend }}
|
||||
php: ${{ steps.changes.outputs.php }}
|
||||
steps:
|
||||
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
id: changes
|
||||
continue-on-error: true
|
||||
with:
|
||||
filters: |
|
||||
frontend:
|
||||
- 'third_party/astroglobe/src/**'
|
||||
- 'third_party/astroglobe/package.json'
|
||||
- 'third_party/astroglobe/package-lock.json'
|
||||
- 'third_party/astroglobe/vite.config.js'
|
||||
- 'third_party/astroglobe/**/*.js'
|
||||
- 'third_party/astroglobe/**/*.ts'
|
||||
- 'third_party/astroglobe/**/*.vue'
|
||||
php:
|
||||
- 'third_party/astroglobe/lib/**'
|
||||
- 'third_party/astroglobe/appinfo/**'
|
||||
- 'third_party/astroglobe/composer.json'
|
||||
- 'third_party/astroglobe/psalm.xml'
|
||||
|
||||
# Node.js build and lint
|
||||
node-build:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Node.js build
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies & build
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: |
|
||||
npm ci
|
||||
npm run build --if-present
|
||||
|
||||
- name: Check webpack build changes
|
||||
run: |
|
||||
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please recompile and commit the assets' && exit 1)"
|
||||
|
||||
# ESLint
|
||||
eslint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: ESLint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
|
||||
# Stylelint
|
||||
stylelint:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.frontend != 'false'
|
||||
name: Stylelint
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Read package.json node and npm engines version
|
||||
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
|
||||
id: versions
|
||||
with:
|
||||
path: third_party/astroglobe
|
||||
fallbackNode: '^20'
|
||||
fallbackNpm: '^10'
|
||||
|
||||
- name: Set up node ${{ steps.versions.outputs.nodeVersion }}
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.nodeVersion }}
|
||||
|
||||
- name: Set up npm ${{ steps.versions.outputs.npmVersion }}
|
||||
run: npm i -g 'npm@${{ steps.versions.outputs.npmVersion }}'
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
CYPRESS_INSTALL_BINARY: 0
|
||||
PUPPETEER_SKIP_DOWNLOAD: true
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run stylelint
|
||||
|
||||
# PHP Code Style
|
||||
php-cs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: PHP CS Fixer
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Lint
|
||||
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
|
||||
|
||||
# Psalm Static Analysis
|
||||
psalm:
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.php != 'false'
|
||||
name: Psalm
|
||||
defaults:
|
||||
run:
|
||||
working-directory: third_party/astroglobe
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Get php version
|
||||
id: versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Set up php${{ steps.versions.outputs.php-min }}
|
||||
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
|
||||
with:
|
||||
php-version: ${{ steps.versions.outputs.php-min }}
|
||||
extensions: bz2, ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, pcntl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib, sqlite, pdo_sqlite
|
||||
coverage: none
|
||||
ini-file: development
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
composer remove nextcloud/ocp --dev || true
|
||||
composer i
|
||||
|
||||
- name: Get OCP version matrix
|
||||
id: ocp-versions
|
||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
|
||||
with:
|
||||
filename: third_party/astroglobe/appinfo/info.xml
|
||||
|
||||
- name: Install OCP for static analysis
|
||||
run: |
|
||||
# Get first OCP version from matrix
|
||||
OCP_VERSION=$(echo '${{ steps.ocp-versions.outputs.ocp-matrix }}' | jq -r '.include[0]."ocp-version"')
|
||||
composer require --dev "nextcloud/ocp:$OCP_VERSION" --ignore-platform-reqs --with-dependencies
|
||||
|
||||
- name: Run Psalm
|
||||
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
|
||||
|
||||
# Summary job
|
||||
summary:
|
||||
permissions:
|
||||
contents: none
|
||||
runs-on: ubuntu-latest
|
||||
needs: [changes, node-build, eslint, stylelint, php-cs, psalm]
|
||||
if: always()
|
||||
name: astroglobe-ci-summary
|
||||
steps:
|
||||
- name: Summary status
|
||||
run: |
|
||||
if ${{ needs.changes.outputs.frontend != 'false' && (needs.node-build.result != 'success' || needs.eslint.result != 'success' || needs.stylelint.result != 'success') }}; then
|
||||
echo "Frontend checks failed"
|
||||
exit 1
|
||||
fi
|
||||
if ${{ needs.changes.outputs.php != 'false' && (needs.php-cs.result != 'success' || needs.psalm.result != 'success') }}; then
|
||||
echo "PHP checks failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed"
|
||||
@@ -15,17 +15,17 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||
- name: Create bump and changelog
|
||||
uses: commitizen-tools/commitizen-action@9615e7be1cf341393c52e865ebbdaa0712176d81 # 0.25.0
|
||||
uses: commitizen-tools/commitizen-action@bb4f1df6601e2a1a891506581b0c53acdc88e07d # 0.26.0
|
||||
with:
|
||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||
changelog_increment_filename: body.md
|
||||
- name: Release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||
with:
|
||||
body_path: "body.md"
|
||||
tag_name: v${{ env.REVISION }}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
prompt: |
|
||||
REPO: ${{ github.repository }}
|
||||
PR NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
Please review this pull request and provide feedback on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or issues
|
||||
- Performance considerations
|
||||
- Security concerns
|
||||
- Test coverage
|
||||
|
||||
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
|
||||
|
||||
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
|
||||
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr:*)'
|
||||
|
||||
@@ -12,11 +12,11 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: RAG Evaluation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
manual_path:
|
||||
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
|
||||
required: false
|
||||
default: 'Nextcloud Manual.pdf'
|
||||
embedding_model:
|
||||
description: 'OpenAI embedding model'
|
||||
required: false
|
||||
default: 'openai/text-embedding-3-small'
|
||||
generation_model:
|
||||
description: 'OpenAI generation model'
|
||||
required: false
|
||||
default: 'openai/gpt-4o-mini'
|
||||
|
||||
jobs:
|
||||
rag-evaluation:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
models: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
|
||||
- name: Run docker compose with vector sync
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: |
|
||||
./docker-compose.yml
|
||||
./docker-compose.ci.yml
|
||||
up-flags: "--build"
|
||||
env:
|
||||
# Environment variables passed to docker-compose.ci.yml
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
|
||||
- name: Wait for Nextcloud to be ready
|
||||
run: |
|
||||
echo "Waiting for Nextcloud..."
|
||||
max_attempts=60
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info | grep -q "401"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "Service did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
|
||||
sleep 5
|
||||
done
|
||||
echo "Nextcloud is ready."
|
||||
|
||||
- name: Wait for MCP server to be ready
|
||||
run: |
|
||||
echo "Waiting for MCP server..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health/live | grep -q "200"; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ $attempt -ge $max_attempts ]; then
|
||||
echo "MCP server did not become ready in time."
|
||||
exit 1
|
||||
fi
|
||||
echo "Attempt $attempt/$max_attempts: MCP not ready, sleeping for 2 seconds..."
|
||||
sleep 2
|
||||
done
|
||||
echo "MCP server is ready."
|
||||
|
||||
- name: Run RAG evaluation tests
|
||||
env:
|
||||
NEXTCLOUD_HOST: "http://localhost:8080"
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
|
||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||
run: |
|
||||
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||
|
||||
- name: Capture MCP container logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== MCP Container Logs ==="
|
||||
docker compose logs mcp --tail=500
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||
with:
|
||||
name: rag-evaluation-results
|
||||
path: |
|
||||
pytest-results.xml
|
||||
retention-days: 30
|
||||
@@ -18,9 +18,9 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
- name: Install Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Build
|
||||
|
||||
@@ -9,9 +9,9 @@ jobs:
|
||||
linting:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
- name: Check format
|
||||
run: |
|
||||
uv run --frozen ruff format --diff
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
with:
|
||||
submodules: 'true'
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
###### Required to build OIDC App ######
|
||||
|
||||
- name: Set up php 8.4
|
||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
||||
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
coverage: none
|
||||
@@ -49,14 +49,14 @@ jobs:
|
||||
|
||||
|
||||
- name: Run docker compose
|
||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||
with:
|
||||
compose-file: "./docker-compose.yml"
|
||||
#compose-flags: "--profile qdrant"
|
||||
up-flags: "--build"
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@5a7eac68fb9809dea845d802897dc5c723910fa3 # v7.1.3
|
||||
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||
|
||||
- name: Install Playwright dependencies
|
||||
run: |
|
||||
@@ -85,4 +85,4 @@ jobs:
|
||||
NEXTCLOUD_USERNAME: "admin"
|
||||
NEXTCLOUD_PASSWORD: "admin"
|
||||
run: |
|
||||
uv run pytest -v --log-cli-level=WARN -m smoke
|
||||
uv run pytest -v --log-cli-level=WARN -m unit -m smoke
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[submodule "oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/oidc"]
|
||||
path = third_party/oidc
|
||||
url = https://github.com/cbcoutinho/oidc
|
||||
[submodule "third_party/notes"]
|
||||
path = third_party/notes
|
||||
url = https://github.com/cbcoutinho/notes
|
||||
|
||||
@@ -1,3 +1,285 @@
|
||||
# Changelog - MCP Server
|
||||
|
||||
All notable changes to the Nextcloud MCP Server will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||
|
||||
## v0.53.0 (2025-12-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- add Alembic database migration system
|
||||
- make chunk modal title clickable link to documents
|
||||
- add native Plotly hover styling for clickable points
|
||||
- add click interactivity to Plotly 3D scatter chart
|
||||
- improve chunk viewer with fixed navigation and markdown rendering
|
||||
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
|
||||
- **auth**: implement refresh token rotation for Nextcloud OIDC
|
||||
- **astrolabe**: enhance unified search and add webhook management
|
||||
- **astrolabe**: add webhook management UI to admin settings
|
||||
- **astrolabe**: add OAuth token refresh and webhook presets
|
||||
- **search**: add file_path metadata and chunk offsets to search results
|
||||
- **astrolabe**: use proper icons and thumbnails in unified search
|
||||
- **astrolabe**: add admin search settings and enhanced UI
|
||||
- **astrolabe**: add unified search provider with clickable file links
|
||||
- **astrolabe**: add 3D PCA visualization for semantic search
|
||||
- **astrolabe**: add Nextcloud PHP app for MCP server management
|
||||
- **vector-sync**: enable background sync in OAuth mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **security**: address critical security issues from PR #401 code review
|
||||
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
|
||||
- **astrolabe**: revert invalid files_pdfviewer URL for file links
|
||||
- resolve type checking warnings for CI
|
||||
- move Alembic to package submodule for Docker compatibility
|
||||
- update unified search results to match chunk viz display
|
||||
- **astrolabe**: handle OAuth refresh token rotation
|
||||
- address critical code review issues (4 fixes)
|
||||
- resolve CI linting issues for Astroglobe
|
||||
|
||||
### Refactor
|
||||
|
||||
- **astrolabe**: extract PDF viewer to dedicated component
|
||||
- **astrolabe**: reframe UI as semantic search service
|
||||
|
||||
## v0.52.1 (2025-12-13)
|
||||
|
||||
### Perf
|
||||
|
||||
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
|
||||
|
||||
## v0.52.0 (2025-12-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- **vector**: add Deck card vector search with visualization support
|
||||
|
||||
## v0.51.0 (2025-12-13)
|
||||
|
||||
### Feat
|
||||
|
||||
- **vector-viz**: add news_item support for links and chunk expansion
|
||||
|
||||
## v0.50.2 (2025-12-13)
|
||||
|
||||
### Fix
|
||||
|
||||
- **news**: revert get_item() to use get_items() + filter
|
||||
|
||||
## v0.50.1 (2025-12-12)
|
||||
|
||||
### Fix
|
||||
|
||||
- Disable DNS rebinding protection for containerized deployments
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
|
||||
## v0.50.0 (2025-12-11)
|
||||
|
||||
### Feat
|
||||
|
||||
- add MCP tool annotations for enhanced UX
|
||||
|
||||
### Fix
|
||||
|
||||
- address PR review feedback
|
||||
|
||||
## v0.49.2 (2025-12-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- Update lockfile
|
||||
|
||||
## v0.49.1 (2025-12-09)
|
||||
|
||||
### Fix
|
||||
|
||||
- Revert mcp version <1.23
|
||||
|
||||
## v0.49.0 (2025-12-08)
|
||||
|
||||
### Feat
|
||||
|
||||
- **news**: add Nextcloud News app integration
|
||||
|
||||
### Fix
|
||||
|
||||
- resolve all type checking errors (8 errors fixed)
|
||||
|
||||
### Refactor
|
||||
|
||||
- **news**: simplify vector sync to fetch all items
|
||||
|
||||
### Perf
|
||||
|
||||
- **news**: use direct API endpoint for get_item()
|
||||
|
||||
## v0.48.6 (2025-12-03)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.23,<1.24
|
||||
|
||||
## v0.48.5 (2025-11-28)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency pillow to v12
|
||||
|
||||
## v0.48.4 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Add rate limit retry logic to OpenAI provider
|
||||
|
||||
## v0.48.3 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Increase MCP sampling timeout to 5 minutes for slower LLMs
|
||||
|
||||
## v0.48.2 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
- Share vector sync state with FastMCP session lifespan via module singleton
|
||||
|
||||
## v0.48.1 (2025-11-23)
|
||||
|
||||
### Fix
|
||||
|
||||
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
|
||||
|
||||
### Refactor
|
||||
|
||||
- Move background tasks to server lifespan and deprecate SSE transport
|
||||
|
||||
## v0.48.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add tag management methods to WebDAV client
|
||||
|
||||
## v0.47.0 (2025-11-23)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add OpenAI provider support for embeddings and generation
|
||||
|
||||
## v0.46.2 (2025-11-22)
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Enable JSON response format for scanner compatibility
|
||||
|
||||
## v0.46.1 (2025-11-22)
|
||||
|
||||
### Perf
|
||||
|
||||
- Optimize vector viz search performance
|
||||
|
||||
## v0.46.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add Smithery CLI deployment support
|
||||
- Implement ADR-016 Smithery stateless deployment mode
|
||||
|
||||
### Fix
|
||||
|
||||
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
|
||||
- **smithery**: Use container runtime pattern for config discovery
|
||||
- Add Smithery lifespan and auth mode detection
|
||||
|
||||
## v0.45.0 (2025-11-22)
|
||||
|
||||
### Feat
|
||||
|
||||
- Add context expansion to semantic search with chunk overlap removal
|
||||
- Use Ollama native batch API in embed_batch()
|
||||
- Implement Qdrant placeholder state management
|
||||
- Switch files to use numeric IDs with file_path resolution
|
||||
- Implement per-chunk vector visualization with context expansion
|
||||
|
||||
### Fix
|
||||
|
||||
- Use alpha_composite for proper RGBA highlight blending
|
||||
- Remove pymupdf.layout.activate() to fix page_chunks behavior
|
||||
- Centralize PDF processing and generate separate images per chunk
|
||||
- Set is_placeholder=False in processor to fix search filtering
|
||||
- Increase placeholder staleness threshold to 5x scan interval
|
||||
- Add placeholder staleness check to prevent duplicate processing
|
||||
- Use empty SparseVector instead of None for placeholders
|
||||
- Return empty array instead of null for query_coords when no results
|
||||
- Align PDF text extraction between indexing and context expansion
|
||||
- Update models and viz to use int-only doc_id
|
||||
- Reconstruct full content for notes to match indexed offsets
|
||||
- Add async/await, PDF metadata, and type safety fixes
|
||||
|
||||
### Refactor
|
||||
|
||||
- Simplify PDF text extraction with single to_markdown call
|
||||
|
||||
### Perf
|
||||
|
||||
- Optimize PDF processing with parallel extraction and single-render highlights
|
||||
|
||||
## v0.44.1 (2025-11-21)
|
||||
|
||||
### Fix
|
||||
|
||||
- **deps**: update dependency mcp to >=1.22,<1.23
|
||||
|
||||
## v0.44.0 (2025-11-19)
|
||||
|
||||
### Feat
|
||||
|
||||
- Improve vector visualization with static assets and fixes
|
||||
- Redesign UI to match Nextcloud ecosystem aesthetic
|
||||
|
||||
### Fix
|
||||
|
||||
- Improve 3D plot rendering with explicit dimensions and window resize support
|
||||
- Preserve 3D plot camera and improve documentation
|
||||
- Preserve 3D plot camera position and fix CSS loading
|
||||
|
||||
## v0.43.0 (2025-11-18)
|
||||
|
||||
### Feat
|
||||
|
||||
- Replace custom document chunker with LangChain MarkdownTextSplitter
|
||||
|
||||
## v0.42.0 (2025-11-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- **viz**: Add dual-score display and improve UI controls
|
||||
|
||||
## v0.41.0 (2025-11-17)
|
||||
|
||||
### Feat
|
||||
|
||||
- add configurable fusion algorithms for BM25 hybrid search
|
||||
- add chunk position tracking to vector indexing and search
|
||||
- add vector viz template and chunk context endpoint
|
||||
|
||||
### Fix
|
||||
|
||||
- prevent infinite loop in DocumentChunker with position tracking
|
||||
- Relax SearchResult validation to support DBSF fusion scores > 1.0
|
||||
|
||||
## v0.40.0 (2025-11-16)
|
||||
|
||||
### Feat
|
||||
|
||||
- add unified provider architecture with Amazon Bedrock support
|
||||
|
||||
### Fix
|
||||
|
||||
- suppress Starlette middleware type warnings in ty checker
|
||||
|
||||
## v0.39.0 (2025-11-16)
|
||||
|
||||
### Feat
|
||||
|
||||
@@ -56,6 +56,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
### MCP Tool Annotations (ADR-017)
|
||||
|
||||
**All tools MUST include annotations** following these patterns:
|
||||
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
# Read-only tools (list, search, get)
|
||||
@mcp.tool(
|
||||
title="Human Readable Name",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True, # Nextcloud is external to MCP server
|
||||
),
|
||||
)
|
||||
|
||||
# Create operations
|
||||
@mcp.tool(
|
||||
title="Create Resource",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Creates new resources each time
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Update operations (with etag/version control)
|
||||
@mcp.tool(
|
||||
title="Update Resource",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # ETag changes = different inputs
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# Delete operations
|
||||
@mcp.tool(
|
||||
title="Delete Resource",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Permanently deletes data
|
||||
idempotentHint=True, # Same end state if called repeatedly
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
|
||||
# HTTP PUT without version control (special case)
|
||||
@mcp.tool(
|
||||
title="Write File",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=True, # Same content = same end state
|
||||
openWorldHint=True,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
**Key Principles**:
|
||||
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
|
||||
- **Destructive**: Operations that permanently delete/overwrite data
|
||||
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
|
||||
- **Titles**: Use human-readable names, not snake_case function names
|
||||
|
||||
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
|
||||
|
||||
### Project Structure
|
||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||
@@ -444,6 +506,29 @@ docker compose exec app php occ user_oidc:provider keycloak
|
||||
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
|
||||
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
|
||||
|
||||
### Querying Nextcloud Application Logs
|
||||
|
||||
**Use this pattern** to inspect Nextcloud application logs during debugging:
|
||||
|
||||
```bash
|
||||
# View recent log entries
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | jq | tail
|
||||
|
||||
# Filter by app
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.app == "astrolabe")' | tail
|
||||
|
||||
# Filter by log level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=FATAL)
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.level >= 3)' | tail
|
||||
|
||||
# Search for specific messages
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.message | contains("OAuth"))' | tail -20
|
||||
|
||||
# View full exception traces
|
||||
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.exception != null)' | tail -5
|
||||
```
|
||||
|
||||
**Log Structure**: Each entry is a JSON object with fields: `reqId`, `level`, `time`, `remoteAddr`, `user`, `app`, `method`, `url`, `message`, `userAgent`, `version`, `exception`
|
||||
|
||||
**For detailed setup, see**:
|
||||
- `docs/installation.md` - Installation guide
|
||||
- `docs/configuration.md` - Configuration options
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Contributing to Nextcloud MCP Server
|
||||
|
||||
## Version Management
|
||||
|
||||
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Scope | Bump Command | Tag Example |
|
||||
|-----------|-------|--------------|-------------|
|
||||
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
Use conventional commits with **scopes** to target specific components:
|
||||
|
||||
```bash
|
||||
# MCP server changes
|
||||
feat(mcp): add calendar sync API
|
||||
fix(mcp): resolve authentication bug
|
||||
|
||||
# Helm chart changes
|
||||
feat(helm): add resource limits
|
||||
docs(helm): update values documentation
|
||||
|
||||
# Astrolabe app changes
|
||||
feat(astrolabe): add dark mode toggle
|
||||
fix(astrolabe): resolve search UI bug
|
||||
```
|
||||
|
||||
**Unscoped commits** default to the MCP server:
|
||||
```bash
|
||||
feat: add new feature # → MCP server (v0.54.0)
|
||||
```
|
||||
|
||||
### Release Workflow
|
||||
|
||||
#### 1. Make Changes with Scoped Commits
|
||||
|
||||
```bash
|
||||
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||
git commit -m "feat(helm): add ingress annotations"
|
||||
git commit -m "feat(mcp): add calendar sync"
|
||||
```
|
||||
|
||||
#### 2. Bump Component Versions
|
||||
|
||||
```bash
|
||||
# Bump MCP server (reads commits with scope=mcp or unscoped)
|
||||
./scripts/bump-mcp.sh
|
||||
# → Creates tag: v0.54.0
|
||||
# → Updates: pyproject.toml, Chart.yaml:appVersion
|
||||
|
||||
# Bump Helm chart (reads commits with scope=helm)
|
||||
./scripts/bump-helm.sh
|
||||
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||
# → Updates: Chart.yaml:version
|
||||
|
||||
# Bump Astrolabe (reads commits with scope=astrolabe)
|
||||
./scripts/bump-astrolabe.sh
|
||||
# → Creates tag: astrolabe-v0.2.0
|
||||
# → Updates: info.xml, package.json
|
||||
```
|
||||
|
||||
#### 3. Push Tags
|
||||
|
||||
```bash
|
||||
git push --follow-tags
|
||||
```
|
||||
|
||||
### Changelog Filtering
|
||||
|
||||
Each component maintains its own `CHANGELOG.md`:
|
||||
|
||||
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||
|
||||
### Manual Version Bumps
|
||||
|
||||
For specific increments:
|
||||
|
||||
```bash
|
||||
# Patch bump (0.53.0 → 0.53.1)
|
||||
uv run cz bump --increment PATCH
|
||||
|
||||
# Minor bump (0.53.0 → 0.54.0)
|
||||
uv run cz bump --increment MINOR
|
||||
|
||||
# Major bump (0.53.0 → 1.0.0)
|
||||
uv run cz bump --increment MAJOR
|
||||
|
||||
# For non-MCP components, use --config
|
||||
cd charts/nextcloud-mcp-server
|
||||
uv run cz --config .cz.toml bump --increment MINOR
|
||||
```
|
||||
|
||||
### Versioning Philosophy
|
||||
|
||||
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||
|
||||
### Chart.yaml Version vs appVersion
|
||||
|
||||
The Helm chart has TWO version fields:
|
||||
|
||||
- **`version`**: Chart packaging version (bumped by `feat(helm):`)
|
||||
- Example: `0.53.0` → `0.54.0` when adding resource limits
|
||||
|
||||
- **`appVersion`**: MCP server version being deployed (bumped by `feat(mcp):`)
|
||||
- Example: `"0.53.0"` → `"0.54.0"` when MCP server releases
|
||||
|
||||
This allows the chart to evolve independently from the application.
|
||||
@@ -1,21 +1,28 @@
|
||||
FROM python:3.12-slim-trixie
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
||||
git \
|
||||
sqlite3
|
||||
tesseract-ocr \
|
||||
sqlite3 && apt clean
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml uv.lock README.md .
|
||||
|
||||
RUN uv sync --locked --no-dev --no-install-project --no-cache
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev --no-editable
|
||||
RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV VIRTUAL_ENV=/app/.venv
|
||||
ENV PATH=/app/.venv/bin:$PATH
|
||||
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
||||
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
|
||||
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "run", "--host", "0.0.0.0"]
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# Dockerfile for Smithery stateless deployment
|
||||
# ADR-016: Stateless mode for multi-user public Nextcloud instances
|
||||
#
|
||||
# This image excludes:
|
||||
# - Vector database dependencies (qdrant-client)
|
||||
# - Background sync workers
|
||||
# - Admin UI routes (/app)
|
||||
# - Semantic search tools
|
||||
#
|
||||
# Features included:
|
||||
# - Core Nextcloud tools (notes, calendar, contacts, files, deck, tables, cookbook)
|
||||
# - Per-session app password authentication
|
||||
# - Multi-user support via Smithery session config
|
||||
|
||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv for fast dependency management
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||
|
||||
# Install dependencies
|
||||
# 1. git (required for caldav dependency from git)
|
||||
# 2. sqlite for development with token db
|
||||
RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
|
||||
git
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
RUN uv sync --locked --no-dev --no-editable --no-cache
|
||||
|
||||
# Set Smithery mode environment variables
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081 by default
|
||||
EXPOSE 8081
|
||||
|
||||
# Health check endpoint
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD uv run python -c "import httpx; httpx.get('http://localhost:${PORT:-8081}/health/live').raise_for_status()"
|
||||
|
||||
CMD ["/app/.venv/bin/smithery-main"]
|
||||
@@ -1,6 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
|
||||
</p>
|
||||
|
||||
# Nextcloud MCP Server
|
||||
|
||||
[](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
|
||||
[](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
|
||||
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
|
||||
|
||||
@@ -13,7 +18,20 @@ This is a **dedicated standalone MCP server** designed for external MCP clients
|
||||
|
||||
## Quick Start
|
||||
|
||||
Get up and running in 60 seconds using Docker:
|
||||
The fastest way to get started is via [Smithery](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server) - no Docker or self-hosting required:
|
||||
|
||||
1. Visit the [Smithery marketplace page](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
|
||||
2. Click "Deploy" and configure:
|
||||
- **Nextcloud URL**: Your Nextcloud instance (e.g., `https://cloud.example.com`)
|
||||
- **Username**: Your Nextcloud username
|
||||
- **App Password**: Generate one in Nextcloud → Settings → Security → Devices & sessions
|
||||
|
||||
> [!NOTE]
|
||||
> Smithery runs in stateless mode without semantic search. For full features, use [Docker](#docker-self-hosted) or see [ADR-016](docs/ADR-016-smithery-stateless-deployment.md).
|
||||
|
||||
## Docker (Self-Hosted)
|
||||
|
||||
For full features including semantic search, run with Docker:
|
||||
|
||||
```bash
|
||||
# 1. Create a minimal configuration
|
||||
@@ -29,10 +47,15 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
|
||||
# 3. Test the connection
|
||||
curl http://127.0.0.1:8000/health/ready
|
||||
|
||||
# 4. Connect to the endpoint
|
||||
http://127.0.0.1:8000/sse
|
||||
|
||||
# Or with --transport streamable-http
|
||||
http://127.0.0.1:8000/mcp
|
||||
```
|
||||
|
||||
**Next Steps:**
|
||||
- Create an app password in Nextcloud: Settings → Security → Devices & sessions
|
||||
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
|
||||
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
|
||||
|
||||
@@ -40,7 +63,7 @@ curl http://127.0.0.1:8000/health/ready
|
||||
|
||||
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
||||
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
|
||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
|
||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
||||
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
||||
@@ -58,7 +81,7 @@ curl http://127.0.0.1:8000/health/ready
|
||||
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
||||
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
||||
| **Sharing** | 10+ | Create and manage shares |
|
||||
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
|
||||
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
|
||||
|
||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
||||
|
||||
@@ -122,7 +145,8 @@ This enables natural language queries and helps discover related content across
|
||||
### Features
|
||||
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
||||
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
|
||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
|
||||
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
||||
|
||||
### Advanced Topics
|
||||
- **[OAuth Architecture](docs/oauth-architecture.md)** - How OAuth works (experimental)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Alembic configuration file for nextcloud-mcp-server
|
||||
|
||||
[alembic]
|
||||
# Path to migration scripts
|
||||
script_location = nextcloud_mcp_server/alembic
|
||||
|
||||
# Template used to generate migration file names
|
||||
# Default: %%(rev)s_%%(slug)s
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# Timezone for migration timestamps
|
||||
# Default: utc
|
||||
timezone = utc
|
||||
|
||||
# Max length of characters to apply to the "slug" field
|
||||
# Default: 40
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# Set to 'true' to run the environment during the 'revision' command
|
||||
# Default: false
|
||||
# revision_environment = false
|
||||
|
||||
# Set to 'true' to allow .pyc and .pyo files without a source .py file
|
||||
# Default: false
|
||||
# sourceless = false
|
||||
|
||||
# Version location specification
|
||||
# Supports single or multiple directories
|
||||
version_locations = nextcloud_mcp_server/alembic/versions
|
||||
|
||||
# Path separator for version locations (required to suppress deprecation warning)
|
||||
# Use os (for cross-platform compatibility)
|
||||
path_separator = os
|
||||
|
||||
# Set to 'true' to search source files recursively in each "version_locations" directory
|
||||
# Default: false
|
||||
# recursive_version_locations = false
|
||||
|
||||
# Output encoding used when revision files are written
|
||||
# Default: utf-8
|
||||
# output_encoding = utf-8
|
||||
|
||||
# Database URL - can be overridden by:
|
||||
# 1. Passing -x database_url=... to alembic commands
|
||||
# 2. Setting in environment via get_database_url() in env.py
|
||||
# Default: sqlite:///app/data/tokens.db
|
||||
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
|
||||
|
||||
[post_write_hooks]
|
||||
# Post-write hooks allow you to run scripts after generating migration files
|
||||
# Example: format migrations with ruff
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = format REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,71 @@
|
||||
Database Migrations for nextcloud-mcp-server
|
||||
============================================
|
||||
|
||||
This directory contains Alembic database migrations for the token storage database.
|
||||
|
||||
Structure
|
||||
---------
|
||||
- env.py: Alembic environment configuration
|
||||
- script.py.mako: Template for generating new migration files
|
||||
- versions/: Directory containing migration scripts
|
||||
|
||||
Usage
|
||||
-----
|
||||
Migrations are managed via the CLI:
|
||||
|
||||
# Upgrade database to latest version
|
||||
uv run nextcloud-mcp-server db upgrade
|
||||
|
||||
# Show current database version
|
||||
uv run nextcloud-mcp-server db current
|
||||
|
||||
# Show migration history
|
||||
uv run nextcloud-mcp-server db history
|
||||
|
||||
# Create a new migration (developers only)
|
||||
uv run nextcloud-mcp-server db migrate "description of changes"
|
||||
|
||||
# Downgrade database by one version (emergency use only)
|
||||
uv run nextcloud-mcp-server db downgrade
|
||||
|
||||
Direct Alembic Usage
|
||||
--------------------
|
||||
You can also use Alembic commands directly:
|
||||
|
||||
# Specify database URL via -x flag
|
||||
uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head
|
||||
|
||||
# Or set in alembic.ini and run
|
||||
uv run alembic upgrade head
|
||||
uv run alembic current
|
||||
uv run alembic history
|
||||
|
||||
Writing Migrations
|
||||
------------------
|
||||
Since we don't use SQLAlchemy models, migrations are written with raw SQL:
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("""
|
||||
ALTER TABLE refresh_tokens
|
||||
ADD COLUMN new_field TEXT
|
||||
""")
|
||||
|
||||
def downgrade() -> None:
|
||||
# SQLite doesn't support DROP COLUMN, use table recreation
|
||||
op.execute("""
|
||||
CREATE TABLE refresh_tokens_new AS
|
||||
SELECT user_id, encrypted_token, ... FROM refresh_tokens
|
||||
""")
|
||||
op.execute("DROP TABLE refresh_tokens")
|
||||
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
|
||||
|
||||
Migration File Naming
|
||||
---------------------
|
||||
Format: YYYYMMDD_HHMM_<revision>_<slug>.py
|
||||
Example: 20251217_2200_001_initial_schema.py
|
||||
|
||||
Notes
|
||||
-----
|
||||
- Migrations run automatically when RefreshTokenStorage.initialize() is called
|
||||
- Existing databases are automatically stamped with the initial version
|
||||
- SQLite has limited ALTER TABLE support - complex changes require table recreation
|
||||
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Apply migration changes to upgrade the database schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert migration changes to downgrade the database schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -3,3 +3,9 @@
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
||||
|
||||
# Set overwrite settings for URL generation (needed for OIDC discovery to return correct URLs)
|
||||
# These ensure that URLs generated by Nextcloud include the correct host:port
|
||||
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
|
||||
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
|
||||
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable news
|
||||
@@ -2,4 +2,30 @@
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
php /var/www/html/occ app:enable notes
|
||||
echo "Installing and configuring notes app for testing..."
|
||||
|
||||
# Check if development notes app is mounted at /opt/apps/notes
|
||||
if [ -d /opt/apps/notes ]; then
|
||||
echo "Development notes app found at /opt/apps/notes"
|
||||
|
||||
# Remove any existing notes app in apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/notes ]; then
|
||||
echo "Removing existing notes in apps..."
|
||||
rm -rf /var/www/html/custom_apps/notes
|
||||
fi
|
||||
|
||||
# Create symlink from apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/notes -> /opt/apps/notes"
|
||||
ln -sf /opt/apps/notes /var/www/html/custom_apps/notes
|
||||
|
||||
echo "Enabling notes app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable notes
|
||||
elif [ -d /var/www/html/custom_apps/notes ]; then
|
||||
echo "notes app directory found in apps (already installed)"
|
||||
php /var/www/html/occ app:enable notes
|
||||
else
|
||||
echo "notes app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install notes
|
||||
php /var/www/html/occ app:enable notes
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euox pipefail
|
||||
|
||||
echo "Installing and configuring Astrolabe app for testing..."
|
||||
|
||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||
if [ -d /opt/apps/astrolabe ]; then
|
||||
echo "Development astrolabe app found at /opt/apps/astrolabe"
|
||||
|
||||
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
|
||||
if [ -e /var/www/html/custom_apps/astrolabe ]; then
|
||||
echo "Removing existing astrolabe in custom_apps..."
|
||||
rm -rf /var/www/html/custom_apps/astrolabe
|
||||
fi
|
||||
|
||||
# Create symlink from custom_apps to the mounted development version
|
||||
# Per Nextcloud docs: apps outside server root need symlinks in server root
|
||||
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
|
||||
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
|
||||
|
||||
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
|
||||
echo "astrolabe app directory found in custom_apps (already installed)"
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
else
|
||||
echo "astrolabe app not found, installing from app store..."
|
||||
php /var/www/html/occ app:install astrolabe
|
||||
php /var/www/html/occ app:enable astrolabe
|
||||
fi
|
||||
|
||||
# Configure MCP server URLs in Nextcloud system config
|
||||
# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network)
|
||||
# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see)
|
||||
php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001'
|
||||
php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001'
|
||||
|
||||
# Create OAuth client for Astrolabe app
|
||||
# The resource_url MUST match what the MCP server expects as token audience
|
||||
# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier
|
||||
MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient"
|
||||
MCP_RESOURCE_URL="http://localhost:8001"
|
||||
MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback"
|
||||
|
||||
echo "Configuring OAuth client for Astrolabe..."
|
||||
|
||||
# Check if client already exists
|
||||
if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then
|
||||
echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..."
|
||||
php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true
|
||||
fi
|
||||
|
||||
# Create OAuth client with correct resource_url for MCP server audience
|
||||
echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL"
|
||||
CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \
|
||||
"Astrolabe" \
|
||||
"$MCP_REDIRECT_URI" \
|
||||
--client_id="$MCP_CLIENT_ID" \
|
||||
--type=confidential \
|
||||
--flow=code \
|
||||
--token_type=jwt \
|
||||
--resource_url="$MCP_RESOURCE_URL" \
|
||||
--allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write")
|
||||
|
||||
echo "$CLIENT_OUTPUT"
|
||||
|
||||
# Extract client_secret from JSON output
|
||||
CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";')
|
||||
|
||||
if [ -n "$CLIENT_SECRET" ]; then
|
||||
echo "Configuring Astrolabe client secret in system config..."
|
||||
php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET"
|
||||
echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..."
|
||||
else
|
||||
echo "⚠ Warning: Could not extract client_secret from OIDC client creation"
|
||||
fi
|
||||
|
||||
# Configure OAuth client ID in system config
|
||||
echo "Configuring Astrolabe client ID in system config..."
|
||||
php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID"
|
||||
echo "✓ Client ID configured: $MCP_CLIENT_ID"
|
||||
|
||||
echo "Astrolabe app installed and configured successfully"
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="80" ry="80" fill="#0082C9"/>
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -0,0 +1,24 @@
|
||||
[tool.commitizen]
|
||||
name = "cz_conventional_commits"
|
||||
version = "0.53.0"
|
||||
tag_format = "nextcloud-mcp-server-$version"
|
||||
version_scheme = "semver"
|
||||
update_changelog_on_bump = true
|
||||
major_version_zero = true
|
||||
|
||||
# Update chart version only (NOT appVersion)
|
||||
version_files = [
|
||||
"Chart.yaml:^version:"
|
||||
]
|
||||
|
||||
# Ignore tags from other components
|
||||
ignored_tag_formats = [
|
||||
"v*", # MCP server tags
|
||||
"astrolabe-v*", # Astrolabe tags
|
||||
]
|
||||
|
||||
# Filter commits by scope
|
||||
[tool.commitizen.customize]
|
||||
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:"
|
||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
|
||||
message_template = "{{change_type}}(helm): {{message}}"
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog - Helm Chart
|
||||
|
||||
All notable changes to the Helm chart will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.53.0] - 2024-12-19
|
||||
|
||||
### Added
|
||||
- Initial independent versioning release
|
||||
- Support for Nextcloud MCP server deployment
|
||||
- Qdrant subchart integration
|
||||
- Ollama subchart integration
|
||||
- Configurable resource limits
|
||||
- Grafana dashboard annotations
|
||||
@@ -1,9 +1,9 @@
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
version: 1.15.5
|
||||
version: 1.16.2
|
||||
- name: ollama
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
version: 1.34.0
|
||||
digest: sha256:d51c97d05be2614b751c0dd7267ef7dc959eff5ebef859c5f895c5c554b7a874
|
||||
generated: "2025-11-09T17:08:02.86648061Z"
|
||||
version: 1.36.0
|
||||
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
|
||||
generated: "2025-12-14T11:07:07.024787592Z"
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: nextcloud-mcp-server
|
||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||
type: application
|
||||
version: 0.39.0
|
||||
appVersion: "0.39.0"
|
||||
version: 0.53.0
|
||||
appVersion: "0.53.0"
|
||||
keywords:
|
||||
- nextcloud
|
||||
- mcp
|
||||
@@ -27,10 +27,10 @@ annotations:
|
||||
grafana_dashboard_folder: "Nextcloud MCP"
|
||||
dependencies:
|
||||
- name: qdrant
|
||||
version: "1.15.5"
|
||||
version: "1.16.2"
|
||||
repository: https://qdrant.github.io/qdrant-helm
|
||||
condition: qdrant.networkMode.deploySubchart
|
||||
- name: ollama
|
||||
version: "1.34.0"
|
||||
version: "1.36.0"
|
||||
repository: https://otwld.github.io/ollama-helm
|
||||
condition: ollama.enabled
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# CI-specific overrides for RAG evaluation pipeline
|
||||
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||
#
|
||||
# Environment variables (set in CI workflow):
|
||||
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||
|
||||
services:
|
||||
mcp:
|
||||
environment:
|
||||
# OpenAI provider configuration (required for CI vector sync)
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||
# Faster sync for CI
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||
# Enable document processing for PDF parsing
|
||||
- ENABLE_DOCUMENT_PROCESSING=true
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
# https://hub.docker.com/_/mariadb
|
||||
db:
|
||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||
image: docker.io/library/mariadb:lts@sha256:6b848cb24fbbd87429917f6c4422ac53c343e85692eb0fef86553e99e4f422f3
|
||||
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
|
||||
restart: always
|
||||
command: --transaction-isolation=READ-COMMITTED
|
||||
volumes:
|
||||
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: docker.io/library/redis:alpine@sha256:28c9c4d7596949a24b183eaaab6455f8e5d55ecbf72d02ff5e2c17fe72671d31
|
||||
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: docker.io/library/nextcloud:32.0.1@sha256:5b043f7ea2f609d5ff5635f475c30d303bec17775a5c3f7fa435e3818e669120
|
||||
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||
restart: always
|
||||
ports:
|
||||
- 0.0.0.0:8080:80
|
||||
@@ -35,6 +35,7 @@ services:
|
||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
||||
# The post-installation hook will register /opt/apps as an additional app directory
|
||||
#- ./third_party:/opt/apps:ro
|
||||
- ./third_party/astrolabe:/opt/apps/astrolabe:ro
|
||||
environment:
|
||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||
- NEXTCLOUD_ADMIN_USER=admin
|
||||
@@ -51,7 +52,7 @@ services:
|
||||
retries: 30
|
||||
|
||||
recipes:
|
||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
||||
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||
restart: always
|
||||
volumes:
|
||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||
@@ -150,6 +151,14 @@ services:
|
||||
# Tokens must contain BOTH MCP and Nextcloud audiences
|
||||
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
|
||||
|
||||
# Vector sync configuration (ADR-007)
|
||||
- VECTOR_SYNC_ENABLED=true
|
||||
- VECTOR_SYNC_SCAN_INTERVAL=60
|
||||
- VECTOR_SYNC_PROCESSOR_WORKERS=1
|
||||
|
||||
# Qdrant configuration - persistent local storage
|
||||
- QDRANT_LOCATION=/app/data/qdrant
|
||||
|
||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||
# Client credentials registered via RFC 7591 and stored in volume
|
||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||
@@ -158,7 +167,7 @@ services:
|
||||
- oauth-tokens:/app/data
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
||||
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||
command:
|
||||
- "start-dev"
|
||||
- "--import-realm"
|
||||
@@ -224,8 +233,28 @@ services:
|
||||
- keycloak-tokens:/app/data
|
||||
- keycloak-oauth-storage:/app/.oauth
|
||||
|
||||
# Smithery stateless deployment mode (ADR-016)
|
||||
# Test with: docker compose --profile smithery up smithery
|
||||
# Then: curl http://localhost:8081/.well-known/mcp-config
|
||||
smithery:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.smithery
|
||||
restart: always
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- 127.0.0.1:8081:8081
|
||||
environment:
|
||||
- SMITHERY_DEPLOYMENT=true
|
||||
- VECTOR_SYNC_ENABLED=false
|
||||
- PORT=8081
|
||||
profiles:
|
||||
- smithery
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.15.5@sha256:0fb8897412abc81d1c0430a899b9a81eb8328aa634e7242d1bc804c1fe8fe863
|
||||
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||
restart: always
|
||||
ports:
|
||||
- 127.0.0.1:6333:6333 # REST API
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# ADR-011: Improving Semantic Search Quality Through Better Chunking and Embeddings
|
||||
|
||||
**Status**: Proposed
|
||||
**Status**: Partially Implemented (Chunking Complete, Embeddings Pending)
|
||||
**Date**: 2025-11-12
|
||||
**Implementation Date**: 2025-11-18 (Chunking)
|
||||
**Authors**: Development Team
|
||||
**Related**: ADR-003 (Vector Database Architecture), ADR-008 (MCP Sampling for RAG)
|
||||
|
||||
@@ -893,3 +894,50 @@ This ADR addresses the root causes of poor semantic search recall:
|
||||
- No new infrastructure or ongoing costs
|
||||
|
||||
**Next Steps**: Approve ADR → Implement changes → Reindex → Validate → Production rollout
|
||||
|
||||
## Implementation Status
|
||||
|
||||
### Completed (2025-11-18)
|
||||
|
||||
**✅ Semantic Markdown-Aware Chunking (Option C1 + C3 Hybrid)**
|
||||
|
||||
Implementation details:
|
||||
- Replaced custom word-based chunking with `MarkdownTextSplitter` from LangChain
|
||||
- Optimized for Nextcloud Notes markdown content with special handling for:
|
||||
- Headers (`#`, `##`, `###`, etc.)
|
||||
- Code blocks (` ``` `)
|
||||
- Lists (`-`, `*`, `1.`)
|
||||
- Horizontal rules (`---`)
|
||||
- Paragraphs and sentences
|
||||
- Maintained `ChunkWithPosition` interface for backward compatibility
|
||||
- Updated configuration defaults:
|
||||
- `DOCUMENT_CHUNK_SIZE`: 512 words → 2048 characters
|
||||
- `DOCUMENT_CHUNK_OVERLAP`: 50 words → 200 characters
|
||||
- Updated unit tests to verify position tracking and boundary preservation
|
||||
- All tests passing with markdown-aware character-based chunking
|
||||
|
||||
**Files Modified**:
|
||||
- `nextcloud_mcp_server/vector/document_chunker.py` - LangChain integration
|
||||
- `nextcloud_mcp_server/config.py` - Character-based defaults
|
||||
- `tests/unit/test_document_chunker.py` - Updated test suite
|
||||
|
||||
**Dependencies Added**:
|
||||
- `langchain-text-splitters>=1.0.0` (already present in `pyproject.toml`)
|
||||
|
||||
**Migration Required**:
|
||||
- ⚠️ Full reindex required to apply new chunking strategy
|
||||
- Existing documents in vector database use old word-based chunks
|
||||
- See "Migration Strategy" section above for reindexing process
|
||||
|
||||
### Pending
|
||||
|
||||
**⏳ Embedding Model Upgrade (Option E1)**
|
||||
|
||||
Still to be implemented:
|
||||
- Switch from `nomic-embed-text` (768-dim) to `mxbai-embed-large-v1` (1024-dim)
|
||||
- Implement dynamic dimension detection in `ollama_provider.py`
|
||||
- Create migration script for collection reindexing
|
||||
- Run benchmarking to validate improvement
|
||||
- Deploy to production with atomic collection swap
|
||||
|
||||
**Estimated Timeline**: 1-2 weeks for implementation and validation
|
||||
|
||||
@@ -147,7 +147,95 @@ This decision consolidates our retrieval logic, eliminates the data consistency
|
||||
|
||||
**Benefits Realized:**
|
||||
- ✅ Consolidated architecture (single Qdrant database for both dense + sparse)
|
||||
- ✅ Native RRF fusion (database-level, more efficient)
|
||||
- ✅ Native fusion algorithms (database-level, more efficient)
|
||||
- ✅ Industry-standard BM25 (replaces custom keyword search)
|
||||
- ✅ Simplified codebase (removed 736 lines of legacy code)
|
||||
- ✅ Better relevance (handles both semantic and keyword queries)
|
||||
- ✅ Configurable fusion methods (RRF and DBSF)
|
||||
|
||||
---
|
||||
|
||||
### 7. Fusion Algorithm Options
|
||||
|
||||
**Update: 2025-11-16**
|
||||
|
||||
The BM25 hybrid search now supports two fusion algorithms for combining dense (semantic) and sparse (BM25) search results:
|
||||
|
||||
#### Reciprocal Rank Fusion (RRF)
|
||||
|
||||
**Default fusion method.** RRF is a widely-used, well-established algorithm that combines rankings from multiple retrieval systems using the reciprocal rank formula:
|
||||
|
||||
```
|
||||
RRF(doc) = Σ 1/(k + rank_i(doc))
|
||||
```
|
||||
|
||||
where `k` is a constant (typically 60) and `rank_i(doc)` is the rank of the document in retrieval system `i`.
|
||||
|
||||
**Characteristics:**
|
||||
- ✅ **General-purpose**: Works well across diverse query types and document collections
|
||||
- ✅ **Rank-based**: Focuses on relative rankings rather than absolute scores
|
||||
- ✅ **Established**: Well-tested, documented, and understood in IR literature
|
||||
- ✅ **Robust**: Less sensitive to score distribution differences between systems
|
||||
|
||||
**When to use RRF:**
|
||||
- Default choice for most use cases
|
||||
- When you have mixed query types (semantic + keyword)
|
||||
- When retrieval systems have very different score ranges
|
||||
- When you want predictable, well-understood behavior
|
||||
|
||||
#### Distribution-Based Score Fusion (DBSF)
|
||||
|
||||
**Alternative fusion method.** DBSF normalizes scores from each retrieval system using distribution statistics before combining them:
|
||||
|
||||
1. **Normalization**: For each query, calculates mean (μ) and standard deviation (σ) of scores
|
||||
2. **Outlier handling**: Uses μ ± 3σ as normalization bounds
|
||||
3. **Fusion**: Sums normalized scores across systems
|
||||
|
||||
**Characteristics:**
|
||||
- ✅ **Score-aware**: Uses actual relevance scores, not just rankings
|
||||
- ✅ **Statistical**: Normalizes based on score distribution properties
|
||||
- ⚠️ **Experimental**: Newer algorithm, less battle-tested than RRF
|
||||
- ⚠️ **Sensitive**: May behave differently depending on score distributions
|
||||
|
||||
**When to use DBSF:**
|
||||
- When retrieval systems have vastly different score ranges that RRF doesn't balance well
|
||||
- When you want to experiment with score-based (vs rank-based) fusion
|
||||
- When statistical normalization better matches your use case
|
||||
- For A/B testing against RRF to measure retrieval quality improvements
|
||||
|
||||
#### Configuration
|
||||
|
||||
Both fusion algorithms are exposed via the `fusion` parameter in MCP tools:
|
||||
|
||||
```python
|
||||
# Use RRF (default)
|
||||
response = await nc_semantic_search(
|
||||
query="async programming",
|
||||
fusion="rrf" # Can be omitted, RRF is default
|
||||
)
|
||||
|
||||
# Use DBSF
|
||||
response = await nc_semantic_search(
|
||||
query="async programming",
|
||||
fusion="dbsf"
|
||||
)
|
||||
```
|
||||
|
||||
The `nc_semantic_search_answer` tool also supports the `fusion` parameter and passes it through to the underlying search.
|
||||
|
||||
#### Future: Configurable Weights
|
||||
|
||||
**Current limitation**: Neither RRF nor DBSF currently support per-system weights (e.g., 0.8 for semantic, 0.2 for BM25). This is a Qdrant platform limitation tracked in [qdrant/qdrant#6067](https://github.com/qdrant/qdrant/issues/6067).
|
||||
|
||||
When Qdrant adds weight support, the `fusion` parameter can be extended to accept weight configurations:
|
||||
|
||||
```python
|
||||
# Hypothetical future API
|
||||
response = await nc_semantic_search(
|
||||
query="async programming",
|
||||
fusion="rrf",
|
||||
fusion_weights={"dense": 0.7, "sparse": 0.3} # Not yet implemented
|
||||
)
|
||||
```
|
||||
|
||||
**Recommendation**: Start with RRF (default). If you encounter cases where keyword matches are under- or over-weighted, experiment with DBSF. Monitor [qdrant/qdrant#6067](https://github.com/qdrant/qdrant/issues/6067) for configurable weight support.
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
# ADR-016: Smithery Stateless Deployment for Multi-User Public Nextcloud Instances
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2025-01-22
|
||||
**Deciders:** Development Team
|
||||
**Related:** ADR-004 (OAuth), ADR-007 (Background Vector Sync), ADR-015 (Unified Provider)
|
||||
|
||||
## Context
|
||||
|
||||
[Smithery](https://smithery.ai) is a hosting platform and marketplace for MCP servers that provides:
|
||||
|
||||
- **Discovery**: Marketplace listing for MCP servers
|
||||
- **Hosting**: Containerized deployment with auto-scaling
|
||||
- **Authentication UI**: OAuth flow presentation for users
|
||||
- **Session Configuration**: Per-user settings passed via URL parameters
|
||||
- **Observability**: Usage logs and monitoring
|
||||
|
||||
### Current Architecture Limitations
|
||||
|
||||
The current nextcloud-mcp-server architecture assumes a **self-hosted deployment** with:
|
||||
|
||||
1. **Persistent Infrastructure**
|
||||
- Qdrant vector database for semantic search
|
||||
- Background sync worker for content indexing
|
||||
- Refresh token storage for offline access
|
||||
|
||||
2. **Single-Tenant Configuration**
|
||||
- Environment variables configure one Nextcloud instance
|
||||
- `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`
|
||||
- Or OAuth with a single IdP
|
||||
|
||||
3. **Stateful Operations**
|
||||
- Vector sync maintains index state across requests
|
||||
- Token storage persists between sessions
|
||||
|
||||
### Smithery Hosting Constraints
|
||||
|
||||
Smithery-hosted containers are **stateless by design**:
|
||||
|
||||
- No persistent storage between requests
|
||||
- No background workers or cron jobs
|
||||
- No databases (Qdrant, Redis, etc.)
|
||||
- Containers may be recycled at any time
|
||||
- Configuration passed per-session via URL parameters
|
||||
|
||||
### Opportunity
|
||||
|
||||
Many users have **publicly accessible Nextcloud instances** and want to:
|
||||
|
||||
1. Try the MCP server without self-hosting infrastructure
|
||||
2. Connect multiple users to different Nextcloud instances
|
||||
3. Use basic Nextcloud tools without semantic search
|
||||
4. Benefit from Smithery's discovery and OAuth UI
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a **stateless deployment mode** for Smithery that:
|
||||
|
||||
1. **Disables stateful features** (vector sync, semantic search)
|
||||
2. **Creates clients per-session** from Smithery configuration
|
||||
3. **Supports multiple Nextcloud instances** via session config
|
||||
4. **Provides a useful subset of tools** that work without infrastructure
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ Smithery-Hosted Stateless Mode │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ MCP Client Smithery │
|
||||
│ (Cursor, Claude) Infrastructure │
|
||||
│ │ │ │
|
||||
│ │ 1. Connect │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ │ │
|
||||
│ │ 2. Config UI │ │
|
||||
│ │◄───────────────────────────┤ User enters: │
|
||||
│ │ (Smithery presents) │ - nextcloud_url │
|
||||
│ │ │ - auth_mode (basic/oauth) │
|
||||
│ │ │ - credentials │
|
||||
│ │ 3. Tool call │ │
|
||||
│ ├───────────────────────────►│ │
|
||||
│ │ + session config │ │
|
||||
│ │ │ │
|
||||
│ │ ┌───────┴───────┐ │
|
||||
│ │ │ MCP Server │ │
|
||||
│ │ │ Container │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ 4. Create │ │
|
||||
│ │ │ client │ │
|
||||
│ │ │ from │ │
|
||||
│ │ │ config │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ │ 5. Call │ │
|
||||
│ │ │ Nextcloud │───────► User's Nextcloud │
|
||||
│ │ │ API │ Instance │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ▼ │ │
|
||||
│ │ 6. Response │ Return result │ │
|
||||
│ │◄───────────────────┤ │ │
|
||||
│ │ └───────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Session Configuration Schema
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
class SmitheryConfigSchema(BaseModel):
|
||||
"""Configuration schema for Smithery session."""
|
||||
|
||||
# Required: Nextcloud instance
|
||||
nextcloud_url: str = Field(
|
||||
...,
|
||||
description="Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
)
|
||||
|
||||
# Authentication mode
|
||||
auth_mode: str = Field(
|
||||
"app_password",
|
||||
description="Authentication method: 'app_password' or 'oauth'"
|
||||
)
|
||||
|
||||
# App Password authentication (recommended for Smithery)
|
||||
username: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud username (required for app_password auth)"
|
||||
)
|
||||
app_password: str | None = Field(
|
||||
None,
|
||||
description="Nextcloud app password (Settings → Security → App passwords)"
|
||||
)
|
||||
|
||||
# OAuth authentication (advanced)
|
||||
# When auth_mode='oauth', Smithery handles the OAuth flow
|
||||
# and passes the access token automatically
|
||||
```
|
||||
|
||||
### Feature Matrix
|
||||
|
||||
| Feature | Self-Hosted | Smithery Stateless |
|
||||
|---------|-------------|-------------------|
|
||||
| **Notes** | | |
|
||||
| List/Search notes | ✓ | ✓ |
|
||||
| Get/Create/Update notes | ✓ | ✓ |
|
||||
| Semantic search | ✓ | ✗ |
|
||||
| **Calendar** | | |
|
||||
| List calendars | ✓ | ✓ |
|
||||
| Get/Create events | ✓ | ✓ |
|
||||
| **Contacts** | | |
|
||||
| List address books | ✓ | ✓ |
|
||||
| Search/Get contacts | ✓ | ✓ |
|
||||
| **Files (WebDAV)** | | |
|
||||
| List/Download files | ✓ | ✓ |
|
||||
| Upload files | ✓ | ✓ |
|
||||
| Search files | ✓ | ✓ (keyword only) |
|
||||
| **Deck** | | |
|
||||
| List boards/cards | ✓ | ✓ |
|
||||
| Create/Update cards | ✓ | ✓ |
|
||||
| **Tables** | | |
|
||||
| List/Query tables | ✓ | ✓ |
|
||||
| Create/Update rows | ✓ | ✓ |
|
||||
| **Cookbook** | | |
|
||||
| List/Get recipes | ✓ | ✓ |
|
||||
| **Semantic Search** | | |
|
||||
| Vector search | ✓ | ✗ |
|
||||
| RAG answers | ✓ | ✗ |
|
||||
| **Background Sync** | | |
|
||||
| Auto-indexing | ✓ | ✗ |
|
||||
| Webhook sync | ✓ | ✗ |
|
||||
| **Admin UI (`/app`)** | | |
|
||||
| Vector sync status | ✓ | ✗ |
|
||||
| Vector visualization | ✓ | ✗ |
|
||||
| Webhook management | ✓ | ✗ |
|
||||
| Session management | ✓ | ✗ |
|
||||
|
||||
### Implementation
|
||||
|
||||
#### 1. Deployment Mode Detection
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/config.py
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
SELF_HOSTED = "self_hosted" # Full features, env-based config
|
||||
SMITHERY_STATELESS = "smithery" # Stateless, session-based config
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment."""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT") == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
```
|
||||
|
||||
#### 2. Session-Based Client Factory
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""Get NextcloudClient - from session config or environment."""
|
||||
|
||||
mode = get_deployment_mode()
|
||||
|
||||
if mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
# Create client from Smithery session config
|
||||
config = ctx.session_config
|
||||
if not config:
|
||||
raise McpError("Session configuration required")
|
||||
|
||||
return NextcloudClient(
|
||||
base_url=config.nextcloud_url,
|
||||
username=config.username,
|
||||
password=config.app_password,
|
||||
)
|
||||
else:
|
||||
# Existing behavior: from environment or OAuth context
|
||||
return await _get_client_from_context(ctx)
|
||||
```
|
||||
|
||||
#### 3. Conditional Tool Registration
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_mcp_server(mode: DeploymentMode) -> FastMCP:
|
||||
"""Create MCP server with mode-appropriate tools."""
|
||||
|
||||
mcp = FastMCP("Nextcloud MCP")
|
||||
|
||||
# Always register core tools
|
||||
configure_notes_tools(mcp)
|
||||
configure_calendar_tools(mcp)
|
||||
configure_contacts_tools(mcp)
|
||||
configure_webdav_tools(mcp)
|
||||
configure_deck_tools(mcp)
|
||||
configure_tables_tools(mcp)
|
||||
configure_cookbook_tools(mcp)
|
||||
|
||||
# Only register stateful tools in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
configure_semantic_tools(mcp) # Requires Qdrant
|
||||
register_oauth_tools(mcp) # Requires token storage
|
||||
|
||||
return mcp
|
||||
```
|
||||
|
||||
#### 4. Exclude Admin UI Routes
|
||||
|
||||
The `/app` admin UI should **not be installed** in Smithery mode because:
|
||||
|
||||
- **Vector sync status** - No vector sync in stateless mode
|
||||
- **Vector visualization** - No Qdrant to visualize
|
||||
- **Webhook management** - No webhook sync without background workers
|
||||
- **Session management** - No persistent sessions to manage
|
||||
|
||||
```python
|
||||
# nextcloud_mcp_server/app.py
|
||||
|
||||
def create_app(mode: DeploymentMode) -> Starlette:
|
||||
"""Create Starlette app with mode-appropriate routes."""
|
||||
|
||||
routes = [
|
||||
Route("/health/live", health_live, methods=["GET"]),
|
||||
Route("/health/ready", health_ready, methods=["GET"]),
|
||||
]
|
||||
|
||||
# Only mount admin UI in self-hosted mode
|
||||
if mode == DeploymentMode.SELF_HOSTED:
|
||||
browser_app = create_browser_app()
|
||||
routes.append(
|
||||
Route("/app", lambda r: RedirectResponse("/app/", status_code=307))
|
||||
)
|
||||
routes.append(Mount("/app", app=browser_app))
|
||||
logger.info("Admin UI mounted at /app")
|
||||
else:
|
||||
logger.info("Admin UI disabled in Smithery stateless mode")
|
||||
|
||||
# Mount FastMCP at root
|
||||
mcp_app = create_mcp_server(mode).streamable_http_app()
|
||||
routes.append(Mount("/", app=mcp_app))
|
||||
|
||||
return Starlette(routes=routes, lifespan=starlette_lifespan)
|
||||
```
|
||||
|
||||
**Endpoints by Mode:**
|
||||
|
||||
| Endpoint | Self-Hosted | Smithery |
|
||||
|----------|-------------|----------|
|
||||
| `/mcp` | ✓ | ✓ |
|
||||
| `/health/live` | ✓ | ✓ |
|
||||
| `/health/ready` | ✓ | ✓ |
|
||||
| `/.well-known/mcp-config` | ✓ | ✓ |
|
||||
| `/app` | ✓ | ✗ |
|
||||
| `/app/vector-sync/status` | ✓ | ✗ |
|
||||
| `/app/vector-viz` | ✓ | ✗ |
|
||||
| `/app/webhooks` | ✓ | ✗ |
|
||||
|
||||
#### 5. Smithery Integration Files
|
||||
|
||||
**smithery.yaml:**
|
||||
```yaml
|
||||
runtime: "container"
|
||||
build:
|
||||
dockerfile: "Dockerfile.smithery"
|
||||
dockerBuildPath: "."
|
||||
startCommand:
|
||||
type: "http"
|
||||
configSchema:
|
||||
type: "object"
|
||||
required: ["nextcloud_url", "username", "app_password"]
|
||||
properties:
|
||||
nextcloud_url:
|
||||
type: "string"
|
||||
title: "Nextcloud URL"
|
||||
description: "Your Nextcloud instance URL (e.g., https://cloud.example.com)"
|
||||
username:
|
||||
type: "string"
|
||||
title: "Username"
|
||||
description: "Your Nextcloud username"
|
||||
app_password:
|
||||
type: "string"
|
||||
title: "App Password"
|
||||
description: "Generate at Settings → Security → App passwords"
|
||||
exampleConfig:
|
||||
nextcloud_url: "https://cloud.example.com"
|
||||
username: "alice"
|
||||
app_password: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
```
|
||||
|
||||
**Dockerfile.smithery:**
|
||||
```dockerfile
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
COPY nextcloud_mcp_server ./nextcloud_mcp_server
|
||||
|
||||
# Install dependencies (without vector/semantic extras)
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
# Set Smithery mode
|
||||
ENV SMITHERY_DEPLOYMENT=true
|
||||
ENV VECTOR_SYNC_ENABLED=false
|
||||
|
||||
# Smithery sets PORT=8081
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["uv", "run", "python", "-m", "nextcloud_mcp_server.smithery_main"]
|
||||
```
|
||||
|
||||
**nextcloud_mcp_server/smithery_main.py:**
|
||||
```python
|
||||
"""Smithery-specific entrypoint for stateless deployment."""
|
||||
|
||||
import os
|
||||
import uvicorn
|
||||
from starlette.middleware.cors import CORSMiddleware
|
||||
|
||||
from nextcloud_mcp_server.app import create_mcp_server
|
||||
from nextcloud_mcp_server.config import DeploymentMode
|
||||
|
||||
def main():
|
||||
# Force stateless mode
|
||||
os.environ["SMITHERY_DEPLOYMENT"] = "true"
|
||||
os.environ["VECTOR_SYNC_ENABLED"] = "false"
|
||||
|
||||
mcp = create_mcp_server(DeploymentMode.SMITHERY_STATELESS)
|
||||
app = mcp.streamable_http_app()
|
||||
|
||||
# Add CORS for browser-based clients
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "OPTIONS"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["mcp-session-id", "mcp-protocol-version"],
|
||||
)
|
||||
|
||||
# Smithery sets PORT environment variable
|
||||
port = int(os.environ.get("PORT", 8081))
|
||||
uvicorn.run(app, host="0.0.0.0", port=port)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
|
||||
1. **App Passwords over User Passwords**
|
||||
- Smithery config encourages app passwords (revocable, scoped)
|
||||
- Documentation guides users to create dedicated app passwords
|
||||
- App passwords can be revoked without changing main password
|
||||
|
||||
2. **HTTPS Required**
|
||||
- `nextcloud_url` must be HTTPS for production use
|
||||
- Validation rejects HTTP URLs in Smithery mode
|
||||
|
||||
3. **No Credential Storage**
|
||||
- Credentials exist only for request duration
|
||||
- No server-side persistence of user credentials
|
||||
- Smithery handles secure config transmission
|
||||
|
||||
4. **Scope Limitation**
|
||||
- Stateless mode cannot access offline_access
|
||||
- No background operations on user's behalf
|
||||
- Clear user expectation: tools work during session only
|
||||
|
||||
### Migration Path
|
||||
|
||||
Users can start with Smithery stateless mode and migrate to self-hosted:
|
||||
|
||||
1. **Try on Smithery** → Basic tools, no setup
|
||||
2. **Self-host for semantic search** → Add Qdrant, enable vector sync
|
||||
3. **Full deployment** → Background sync, webhooks, multi-user OAuth
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
1. **Lower barrier to entry** - Users can try without infrastructure
|
||||
2. **Multi-user support** - Each session connects to different Nextcloud
|
||||
3. **Smithery ecosystem** - Discovery, observability, OAuth UI
|
||||
4. **Clear feature tiers** - Stateless (simple) vs self-hosted (full)
|
||||
|
||||
### Negative
|
||||
|
||||
1. **No semantic search** - Key differentiator unavailable on Smithery
|
||||
2. **Per-request auth** - Credentials sent with each request
|
||||
3. **No offline access** - Cannot perform background operations
|
||||
4. **Maintenance burden** - Two deployment modes to support
|
||||
|
||||
### Neutral
|
||||
|
||||
1. **Feature subset** - May encourage users to self-host for full features
|
||||
2. **Documentation needs** - Clear guidance on mode differences required
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### 1. External MCP Only
|
||||
|
||||
**Approach:** Only support self-hosted external MCP registration on Smithery.
|
||||
|
||||
**Rejected because:**
|
||||
- Higher barrier to entry for new users
|
||||
- Misses opportunity for Smithery marketplace visibility
|
||||
- Users want to try before committing to infrastructure
|
||||
|
||||
### 2. Embedded Vector DB (SQLite-vec)
|
||||
|
||||
**Approach:** Use SQLite with vector extensions for per-request indexing.
|
||||
|
||||
**Rejected because:**
|
||||
- No persistence between requests anyway
|
||||
- Indexing latency too high for synchronous requests
|
||||
- Complexity without benefit in stateless context
|
||||
|
||||
### 3. External Vector DB Service
|
||||
|
||||
**Approach:** Connect to Pinecone/Weaviate Cloud from Smithery container.
|
||||
|
||||
**Rejected because:**
|
||||
- Adds external dependency and cost
|
||||
- Per-user collections require complex multi-tenancy
|
||||
- Sync still impossible without background workers
|
||||
|
||||
### 4. Hybrid: Smithery + User's Qdrant
|
||||
|
||||
**Approach:** User provides their own Qdrant URL in session config.
|
||||
|
||||
**Considered for future:**
|
||||
- Could enable semantic search for advanced users
|
||||
- Adds complexity to session config
|
||||
- Sync still requires external trigger (manual or webhook)
|
||||
|
||||
## References
|
||||
|
||||
- [Smithery Documentation](https://smithery.ai/docs)
|
||||
- [Smithery Session Configuration](https://smithery.ai/docs/build/session-config)
|
||||
- [Smithery External MCPs](https://smithery.ai/docs/build/external)
|
||||
- [MCP Streamable HTTP Transport](https://modelcontextprotocol.io/docs/concepts/transports)
|
||||
- [Nextcloud App Passwords](https://docs.nextcloud.com/server/latest/user_manual/en/session_management.html#app-passwords)
|
||||
@@ -0,0 +1,506 @@
|
||||
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
|
||||
|
||||
## Status
|
||||
|
||||
Implemented
|
||||
|
||||
## Context
|
||||
|
||||
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
|
||||
|
||||
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
|
||||
- No behavioral hints for clients about read-only, destructive, or idempotent operations
|
||||
- Missing parameter descriptions for better auto-completion and inline help
|
||||
- Clients cannot optimize caching, warn before destructive operations, or retry safely
|
||||
|
||||
### Available MCP Annotations
|
||||
|
||||
The MCP SDK provides three types of annotations:
|
||||
|
||||
#### 1. Tool Decorator Parameters
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Human-Readable Name",
|
||||
description="Tool description", # Can also come from docstring
|
||||
annotations=ToolAnnotations(...),
|
||||
icons=[Icon(...)] # Optional visual icons
|
||||
)
|
||||
```
|
||||
|
||||
#### 2. ToolAnnotations Behavioral Hints
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
ToolAnnotations(
|
||||
title="Alternative Title", # Decorator title takes precedence
|
||||
readOnlyHint=True, # Tool doesn't modify data
|
||||
destructiveHint=True, # Tool may delete/overwrite data
|
||||
idempotentHint=True, # Repeated calls with same args are safe
|
||||
openWorldHint=True # Interacts with external entities
|
||||
)
|
||||
```
|
||||
|
||||
#### 3. Parameter Descriptions
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
async def tool(
|
||||
param: str = Field(description="What this parameter does"),
|
||||
ctx: Context
|
||||
):
|
||||
```
|
||||
|
||||
### Idempotency Analysis
|
||||
|
||||
**Important**: Idempotency means calling with **the same inputs** produces the same result.
|
||||
|
||||
**NOT Idempotent** (different inputs each call):
|
||||
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
|
||||
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
|
||||
- Different input (stale etag) → different result (error)
|
||||
- **Creates**: `create_note(title="X")` → creates note 1
|
||||
- Second call → creates note 2 (different result)
|
||||
- **Append operations**: `append_content(id=1, text="X")` → adds X once
|
||||
- Second call → adds X again (different result)
|
||||
|
||||
**Idempotent**:
|
||||
- **Deletes**: `delete_note(id=1)` → note deleted
|
||||
- Second call → 404 or success (same end state: note doesn't exist)
|
||||
- Note: May return different status code, but end state is identical
|
||||
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
|
||||
- Second call → file still has "Hello" (same end state)
|
||||
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
|
||||
- **Set operations**: `set_property(id=1, value="X")` → property = X
|
||||
- Second call → property still = X (same result)
|
||||
- Note: Nextcloud updates with etags use version control, so not idempotent
|
||||
|
||||
**Read-Only** (always idempotent, never destructive):
|
||||
- All list, search, get operations
|
||||
|
||||
## Decision
|
||||
|
||||
Add annotations to all 101 tools in three phases:
|
||||
|
||||
### Phase 1: Titles (Quick Win)
|
||||
Add human-readable titles to all tools:
|
||||
|
||||
```python
|
||||
@mcp.tool(title="Create Note")
|
||||
async def nc_notes_create_note(...):
|
||||
```
|
||||
|
||||
**Effort**: 2-3 hours
|
||||
**Impact**: Immediate UX improvement
|
||||
|
||||
### Phase 2: ToolAnnotations (Behavioral Hints)
|
||||
Add annotations based on corrected categorization:
|
||||
|
||||
```python
|
||||
# Read-only tools
|
||||
@mcp.tool(
|
||||
title="Search Notes",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=True # Nextcloud is external to MCP server
|
||||
)
|
||||
)
|
||||
|
||||
# Delete tools (idempotent: same end state)
|
||||
@mcp.tool(
|
||||
title="Delete Note",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True,
|
||||
idempotentHint=True, # Deleting deleted item = same end state
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Create tools (not idempotent: creates multiple items)
|
||||
@mcp.tool(
|
||||
title="Create Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Update tools with etag (not idempotent: etag changes)
|
||||
@mcp.tool(
|
||||
title="Update Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # Etag required = different inputs each time
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
|
||||
# Append operations (not idempotent: adds content each time)
|
||||
@mcp.tool(
|
||||
title="Append to Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False,
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
**Effort**: 4-6 hours
|
||||
**Impact**: Better client behavior (caching, warnings, retry logic)
|
||||
|
||||
### Phase 3: Parameter Descriptions
|
||||
Add Field() descriptions to parameters:
|
||||
|
||||
```python
|
||||
from pydantic import Field
|
||||
|
||||
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
|
||||
async def nc_notes_create_note(
|
||||
title: str = Field(description="The title of the note"),
|
||||
content: str = Field(description="Markdown content of the note"),
|
||||
category: str = Field(description="Category or folder name for organizing"),
|
||||
ctx: Context
|
||||
) -> CreateNoteResponse:
|
||||
```
|
||||
|
||||
**Effort**: 6-8 hours
|
||||
**Impact**: Better auto-completion and inline help
|
||||
|
||||
## Tool Categorization
|
||||
|
||||
### Read-Only Tools (~40 tools)
|
||||
**Pattern**: List, search, get operations
|
||||
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_search_notes` → "Search Notes"
|
||||
- `nc_webdav_list_directory` → "List Files and Directories"
|
||||
- `nc_calendar_list_calendars` → "List Calendars"
|
||||
- `nc_contacts_get_contact` → "Get Contact"
|
||||
- `nc_semantic_search` → "Semantic Search"
|
||||
- `check_logged_in` → "Check Server Login Status"
|
||||
|
||||
### Create Tools (~20 tools)
|
||||
**Pattern**: Create new resources
|
||||
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_create_note` → "Create Note"
|
||||
- `nc_calendar_create_event` → "Create Calendar Event"
|
||||
- `nc_contacts_create_contact` → "Create Contact"
|
||||
- `deck_create_card` → "Create Kanban Card"
|
||||
- `nc_tables_create_row` → "Create Table Row"
|
||||
|
||||
### Update Tools (~25 tools)
|
||||
**Pattern**: Modify existing resources with etag
|
||||
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_update_note` → "Update Note"
|
||||
- `nc_calendar_update_event` → "Update Calendar Event"
|
||||
- `nc_contacts_update_contact` → "Update Contact"
|
||||
- `deck_update_card` → "Update Kanban Card"
|
||||
|
||||
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
|
||||
|
||||
### Append/Accumulate Tools (~5 tools)
|
||||
**Pattern**: Add content without replacing
|
||||
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_append_content` → "Append to Note"
|
||||
|
||||
**Rationale**: Each call adds content, changing the result = NOT idempotent.
|
||||
|
||||
### Delete Tools (~10 tools)
|
||||
**Pattern**: Remove resources
|
||||
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
|
||||
|
||||
Examples:
|
||||
- `nc_notes_delete_note` → "Delete Note"
|
||||
- `nc_webdav_delete_resource` → "Delete File or Directory"
|
||||
- `nc_calendar_delete_event` → "Delete Calendar Event"
|
||||
- `nc_contacts_delete_contact` → "Delete Contact"
|
||||
|
||||
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
|
||||
|
||||
### Special Cases
|
||||
|
||||
#### OAuth Provisioning Tools
|
||||
```python
|
||||
# Not read-only but requires user interaction
|
||||
@mcp.tool(
|
||||
title="Grant Server Access to Nextcloud",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=False,
|
||||
idempotentHint=False, # Creates new OAuth session each time
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
async def provision_nextcloud_access(ctx: Context):
|
||||
```
|
||||
|
||||
#### Semantic Search (Closed World)
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Semantic Search",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True,
|
||||
openWorldHint=False # Searches only indexed Nextcloud data
|
||||
)
|
||||
)
|
||||
async def nc_semantic_search(query: str, ctx: Context):
|
||||
```
|
||||
|
||||
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
|
||||
|
||||
## Tool Priority Matrix
|
||||
|
||||
### Critical Priority (~2 tools)
|
||||
OAuth tools required for server functionality:
|
||||
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
|
||||
- `check_logged_in` → "Check Server Login Status"
|
||||
|
||||
### High Priority (~50 tools)
|
||||
Most commonly used modules:
|
||||
- **Notes** (14 tools): Create, read, update, delete notes
|
||||
- **WebDAV** (13 tools): File operations
|
||||
- **Calendar** (15 tools): Events and todos
|
||||
- **Semantic Search** (6 tools): AI-powered search
|
||||
- **Contacts** (9 tools): Address book operations
|
||||
|
||||
### Medium Priority (~35 tools)
|
||||
Secondary functionality:
|
||||
- **Deck** (9 tools): Kanban boards
|
||||
- **Tables** (7 tools): Structured data
|
||||
- **Sharing** (5 tools): File sharing
|
||||
|
||||
### Low Priority (~14 tools)
|
||||
Less frequently used:
|
||||
- **Cookbook** (8 tools): Recipe management
|
||||
- **News** (6 tools): RSS feeds
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Week 1: Phase 1 - Titles
|
||||
- Add human-readable titles to all 101 tools
|
||||
- Update tool name mapping in documentation
|
||||
- Manual test in MCP inspector
|
||||
|
||||
### Week 2: Phase 2 - ToolAnnotations (High Priority)
|
||||
- Add annotations to Critical and High priority tools (~52 tools)
|
||||
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
|
||||
- Add unit tests validating annotation presence
|
||||
|
||||
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
|
||||
- Complete remaining tools (~49 tools)
|
||||
- Deck, Tables, Contacts, Cookbook, News
|
||||
- Update tool listings in README
|
||||
|
||||
### Week 4: Phase 3 - Parameter Descriptions
|
||||
- Add Field() descriptions to Critical/High priority tools
|
||||
- Start with OAuth, Notes, WebDAV modules
|
||||
- Incremental completion over time
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Users
|
||||
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
|
||||
- **Safety**: Warnings before destructive operations
|
||||
- **Better help**: Parameter descriptions in auto-completion
|
||||
- **Confidence**: Know which operations are safe to retry
|
||||
|
||||
### For MCP Clients
|
||||
- **Caching**: Cache results from read-only tools
|
||||
- **Safety prompts**: Warn before destructiveHint=true
|
||||
- **Retry logic**: Safely retry idempotent operations
|
||||
- **UI organization**: Group by behavior (reads vs writes vs deletes)
|
||||
- **Performance**: Optimize based on hints
|
||||
|
||||
### For Developers
|
||||
- **Self-documenting**: Behavior is explicit
|
||||
- **Consistency**: Standard patterns across codebase
|
||||
- **Testing**: Validate annotations match implementation
|
||||
- **Maintenance**: Clear expectations for new tools
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Immediate UX improvement with minimal effort
|
||||
- Clients can make smarter decisions
|
||||
- Self-documenting code
|
||||
- Follows MCP best practices
|
||||
|
||||
### Negative
|
||||
- Initial effort to add annotations (12-15 hours total)
|
||||
- Must maintain annotations when adding new tools
|
||||
- Risk of incorrect annotations misleading clients
|
||||
|
||||
### Neutral
|
||||
- Annotations are hints, not guarantees
|
||||
- Clients may ignore annotations
|
||||
- Backward compatible (additive change)
|
||||
|
||||
### Mitigations
|
||||
- **Incorrect annotations**: Add tests validating behavior matches hints
|
||||
- **Maintenance burden**: Add to code review checklist and tool template
|
||||
- **Documentation**: Update CLAUDE.md with annotation guidelines
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Annotated Tool (Delete)
|
||||
|
||||
```python
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import Field
|
||||
|
||||
@mcp.tool(
|
||||
title="Delete Note",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, # Deletes data permanently
|
||||
idempotentHint=True, # Same end state (note doesn't exist)
|
||||
openWorldHint=True # Nextcloud is external
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_delete_note(
|
||||
note_id: int = Field(description="The ID of the note to delete permanently"),
|
||||
ctx: Context
|
||||
) -> DeleteNoteResponse:
|
||||
"""Delete a note permanently (requires notes:write scope)"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
### Complete Annotated Tool (Update)
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Update Note",
|
||||
annotations=ToolAnnotations(
|
||||
idempotentHint=False, # NOT idempotent: etag changes each update
|
||||
openWorldHint=True
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:write")
|
||||
@instrument_tool
|
||||
async def nc_notes_update_note(
|
||||
note_id: int = Field(description="The ID of the note to update"),
|
||||
title: str | None = Field(
|
||||
default=None,
|
||||
description="New title (omit to keep current)"
|
||||
),
|
||||
content: str | None = Field(
|
||||
default=None,
|
||||
description="New markdown content (omit to keep current)"
|
||||
),
|
||||
category: str | None = Field(
|
||||
default=None,
|
||||
description="New category/folder (omit to keep current)"
|
||||
),
|
||||
etag: str = Field(
|
||||
description="ETag from get_note (prevents concurrent modification)"
|
||||
),
|
||||
ctx: Context
|
||||
) -> UpdateNoteResponse:
|
||||
"""Update an existing note's title, content, or category.
|
||||
|
||||
The etag parameter is required to prevent overwriting concurrent changes.
|
||||
Get the current ETag by first calling nc_notes_get_note.
|
||||
If the note has been modified since you retrieved it, the update will fail.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
### Complete Annotated Tool (Read-Only)
|
||||
|
||||
```python
|
||||
@mcp.tool(
|
||||
title="Search Notes",
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=True, # Doesn't modify data
|
||||
openWorldHint=True # Queries Nextcloud
|
||||
)
|
||||
)
|
||||
@require_scopes("notes:read")
|
||||
@instrument_tool
|
||||
async def nc_notes_search_notes(
|
||||
query: str = Field(description="Search term to match in note titles or content"),
|
||||
ctx: Context
|
||||
) -> SearchNotesResponse:
|
||||
"""Search notes by title or content, returning id, title, and category.
|
||||
|
||||
This is a read-only operation that searches across all user notes.
|
||||
Use nc_notes_get_note to retrieve the full content of matching notes.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
# ... implementation ...
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
Add tests validating annotation presence and correctness:
|
||||
|
||||
```python
|
||||
def test_notes_tools_have_annotations():
|
||||
"""Verify all notes tools have appropriate annotations."""
|
||||
tools = get_registered_tools(mcp)
|
||||
|
||||
# Check create tool
|
||||
create_tool = tools["nc_notes_create_note"]
|
||||
assert create_tool.title == "Create Note"
|
||||
assert create_tool.annotations.idempotentHint is False
|
||||
|
||||
# Check delete tool
|
||||
delete_tool = tools["nc_notes_delete_note"]
|
||||
assert delete_tool.title == "Delete Note"
|
||||
assert delete_tool.annotations.destructiveHint is True
|
||||
assert delete_tool.annotations.idempotentHint is True
|
||||
|
||||
# Check read-only tool
|
||||
search_tool = tools["nc_notes_search_notes"]
|
||||
assert search_tool.title == "Search Notes"
|
||||
assert search_tool.annotations.readOnlyHint is True
|
||||
```
|
||||
|
||||
### Integration Tests
|
||||
- Verify existing tests pass with annotations
|
||||
- Manual testing in MCP inspector/client
|
||||
|
||||
### Documentation Updates
|
||||
- Update README tool listings with new titles
|
||||
- Add annotation guidelines to CLAUDE.md
|
||||
- Include examples in developer documentation
|
||||
|
||||
## Resolved Questions
|
||||
|
||||
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
|
||||
- **Decision**: Mark as `idempotentHint=True`
|
||||
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
|
||||
|
||||
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
|
||||
- **Decision**: Mark as `openWorldHint=True`
|
||||
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
|
||||
|
||||
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
|
||||
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
|
||||
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
|
||||
|
||||
## References
|
||||
|
||||
- MCP Python SDK: `/home/chris/Software/python-sdk/`
|
||||
- ToolAnnotations spec: `src/mcp/types.py:1247`
|
||||
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
|
||||
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
|
||||
|
||||
## Decision Timeline
|
||||
|
||||
- **Proposed**: 2025-12-11
|
||||
- **Reviewed**: 2025-12-11 (Self-review during implementation)
|
||||
- **Accepted**: 2025-12-11
|
||||
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
|
||||
@@ -0,0 +1,104 @@
|
||||
# MCP 1.23.x DNS Rebinding Protection Fix
|
||||
|
||||
## Problem
|
||||
|
||||
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
|
||||
|
||||
### Root Cause
|
||||
|
||||
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
|
||||
|
||||
```python
|
||||
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
|
||||
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
|
||||
transport_security = TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
|
||||
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
|
||||
)
|
||||
```
|
||||
|
||||
### What Was Happening
|
||||
|
||||
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
|
||||
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
|
||||
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
|
||||
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
|
||||
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
|
||||
|
||||
### Why `--host 0.0.0.0` Didn't Help
|
||||
|
||||
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
|
||||
|
||||
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
|
||||
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
|
||||
|
||||
## Solution
|
||||
|
||||
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
|
||||
|
||||
### Changes Made
|
||||
|
||||
Modified `nextcloud_mcp_server/app.py`:
|
||||
|
||||
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
|
||||
2. **Updated all three FastMCP initializations**:
|
||||
- OAuth mode (line 1015)
|
||||
- Smithery stateless mode (line 1030)
|
||||
- BasicAuth mode (line 1040)
|
||||
|
||||
Each now includes:
|
||||
```python
|
||||
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||
```
|
||||
|
||||
## Impact
|
||||
|
||||
### ✅ What This Fixes
|
||||
|
||||
- **Kubernetes deployments**: Requests with k8s service DNS names now work
|
||||
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
|
||||
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
|
||||
- **Ingress controllers**: Requests via ingress hostnames now work
|
||||
|
||||
### 🔒 Security Considerations
|
||||
|
||||
DNS rebinding protection defends against attacks where:
|
||||
1. Attacker controls a DNS domain (e.g., `evil.com`)
|
||||
2. DNS initially resolves to attacker's IP
|
||||
3. After victim's browser caches the origin, DNS changes to victim's localhost
|
||||
4. Attacker's page can now make requests to victim's localhost services
|
||||
|
||||
**Why it's safe to disable for this deployment:**
|
||||
|
||||
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
|
||||
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
|
||||
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
|
||||
4. **Host header validation inappropriate** for multi-tenant k8s environments
|
||||
|
||||
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
|
||||
|
||||
```python
|
||||
transport_security=TransportSecuritySettings(
|
||||
enable_dns_rebinding_protection=True,
|
||||
allowed_hosts=[
|
||||
"nextcloud-mcp-server.default.svc.cluster.local:*",
|
||||
"mcp.example.com:*",
|
||||
# Add all your expected Host header values
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- ✅ Ruff linting passes
|
||||
- ✅ Type checking passes (pre-existing warnings unrelated)
|
||||
- ✅ Module imports successfully
|
||||
- ✅ Compatible with MCP 1.23.x
|
||||
|
||||
## References
|
||||
|
||||
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
|
||||
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
|
||||
- Issue #373 (original report of k8s breakage)
|
||||
- PR #382 (MCP 1.23.x upgrade)
|
||||
@@ -0,0 +1,301 @@
|
||||
# Database Migrations
|
||||
|
||||
This document describes the database migration system for nextcloud-mcp-server's token storage database.
|
||||
|
||||
## Overview
|
||||
|
||||
The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides:
|
||||
|
||||
- **Version Control**: Track schema changes in Git
|
||||
- **Rollback Support**: Safely downgrade schema if needed
|
||||
- **Audit Trail**: Migration files serve as schema changelog
|
||||
- **Automated Upgrades**: Database schema updates automatically on startup
|
||||
|
||||
## Architecture
|
||||
|
||||
### Migration Strategy
|
||||
|
||||
The system handles three scenarios:
|
||||
|
||||
1. **New Database**: Runs migrations from scratch to create all tables
|
||||
2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes)
|
||||
3. **Alembic-Managed Database**: Upgrades to latest version automatically
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
nextcloud-mcp-server/
|
||||
├── alembic/ # Alembic migrations
|
||||
│ ├── versions/ # Migration scripts
|
||||
│ │ └── 20251217_2200_001_initial_schema.py
|
||||
│ ├── env.py # Alembic environment
|
||||
│ ├── script.py.mako # Migration template
|
||||
│ └── README # Migration usage guide
|
||||
├── alembic.ini # Alembic configuration
|
||||
└── nextcloud_mcp_server/
|
||||
├── auth/storage.py # Uses migrations on init
|
||||
└── migrations.py # Migration utilities
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Automatic Migration on Startup
|
||||
|
||||
Migrations run automatically when the server starts:
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server
|
||||
```
|
||||
|
||||
The `RefreshTokenStorage.initialize()` method:
|
||||
1. Checks if database is Alembic-managed
|
||||
2. Stamps pre-Alembic databases with initial revision
|
||||
3. Upgrades to latest version
|
||||
|
||||
### Manual Migration Commands
|
||||
|
||||
```bash
|
||||
# Show current database version
|
||||
uv run nextcloud-mcp-server db current
|
||||
|
||||
# Upgrade database to latest version
|
||||
uv run nextcloud-mcp-server db upgrade
|
||||
|
||||
# Show migration history
|
||||
uv run nextcloud-mcp-server db history
|
||||
|
||||
# Downgrade by one version (emergency use only)
|
||||
uv run nextcloud-mcp-server db downgrade
|
||||
|
||||
# Specify custom database path
|
||||
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`)
|
||||
|
||||
## Creating Migrations (Developers)
|
||||
|
||||
### Step 1: Create Migration File
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server db migrate "add user preferences table"
|
||||
```
|
||||
|
||||
This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions.
|
||||
|
||||
### Step 2: Write Migration SQL
|
||||
|
||||
Since we don't use SQLAlchemy models, write raw SQL:
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
"""Add user preferences table."""
|
||||
op.execute("""
|
||||
CREATE TABLE user_preferences (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
theme TEXT DEFAULT 'light',
|
||||
language TEXT DEFAULT 'en',
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
op.execute("""
|
||||
CREATE INDEX idx_user_preferences_user_id
|
||||
ON user_preferences(user_id)
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove user preferences table."""
|
||||
op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id")
|
||||
op.execute("DROP TABLE IF EXISTS user_preferences")
|
||||
```
|
||||
|
||||
### Step 3: Test Migration
|
||||
|
||||
```bash
|
||||
# Test upgrade
|
||||
uv run nextcloud-mcp-server db upgrade -d /tmp/test.db
|
||||
|
||||
# Verify schema
|
||||
sqlite3 /tmp/test.db ".schema"
|
||||
|
||||
# Test downgrade
|
||||
uv run nextcloud-mcp-server db downgrade -d /tmp/test.db
|
||||
|
||||
# Verify removal
|
||||
sqlite3 /tmp/test.db ".schema"
|
||||
```
|
||||
|
||||
### Step 4: Commit Migration
|
||||
|
||||
```bash
|
||||
git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py
|
||||
git commit -m "feat: add user preferences table migration"
|
||||
```
|
||||
|
||||
## SQLite Limitations
|
||||
|
||||
SQLite has limited `ALTER TABLE` support:
|
||||
|
||||
### Supported Operations
|
||||
|
||||
- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...`
|
||||
- ✅ Rename table: `ALTER TABLE old RENAME TO new`
|
||||
- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+)
|
||||
|
||||
### Unsupported Operations (Requires Table Recreation)
|
||||
|
||||
- ❌ Drop column
|
||||
- ❌ Change column type
|
||||
- ❌ Add constraints to existing columns
|
||||
|
||||
### Table Recreation Pattern
|
||||
|
||||
For complex schema changes:
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
# Create new table with desired schema
|
||||
op.execute("""
|
||||
CREATE TABLE refresh_tokens_new (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
new_field TEXT, -- New column
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Copy data from old table
|
||||
op.execute("""
|
||||
INSERT INTO refresh_tokens_new
|
||||
(user_id, encrypted_token, expires_at, created_at)
|
||||
SELECT user_id, encrypted_token, expires_at, created_at
|
||||
FROM refresh_tokens
|
||||
""")
|
||||
|
||||
# Drop old table and rename new table
|
||||
op.execute("DROP TABLE refresh_tokens")
|
||||
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
|
||||
|
||||
# Recreate indexes
|
||||
op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)")
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py`
|
||||
- **Revision IDs**: Sequential numbers (`001`, `002`, `003`)
|
||||
- **Descriptions**: Imperative mood ("add table", "remove column")
|
||||
|
||||
### Migration Guidelines
|
||||
|
||||
1. **Test Thoroughly**: Test both upgrade and downgrade paths
|
||||
2. **Preserve Data**: Ensure data migration logic is correct
|
||||
3. **Document Changes**: Add comments explaining complex operations
|
||||
4. **Small Changes**: One logical change per migration
|
||||
5. **No Breaking Changes**: Maintain backward compatibility when possible
|
||||
|
||||
### Downgrade Considerations
|
||||
|
||||
- **Data Loss**: Downgrade may lose data (dropped columns, tables)
|
||||
- **Confirmation**: Downgrade command requires explicit confirmation
|
||||
- **Testing**: Always test downgrade path before deploying
|
||||
- **Emergency Only**: Use downgrades only for critical rollbacks
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
### Pre-Alembic Databases
|
||||
|
||||
Existing databases created before Alembic integration are automatically detected and stamped with revision `001`:
|
||||
|
||||
1. Server detects no `alembic_version` table
|
||||
2. Checks if `refresh_tokens` table exists
|
||||
3. If yes, stamps database with `001` (no schema changes)
|
||||
4. Future updates use normal migration path
|
||||
|
||||
### Migration Path
|
||||
|
||||
```
|
||||
Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ...
|
||||
New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Fails
|
||||
|
||||
```bash
|
||||
# Check current state
|
||||
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||
|
||||
# View migration history
|
||||
uv run nextcloud-mcp-server db history -d /path/to/tokens.db
|
||||
|
||||
# Manually inspect database
|
||||
sqlite3 /path/to/tokens.db ".schema"
|
||||
```
|
||||
|
||||
### Reset to Initial State
|
||||
|
||||
**WARNING: This destroys all data!**
|
||||
|
||||
```bash
|
||||
# Downgrade to base (empty database)
|
||||
uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base
|
||||
|
||||
# Upgrade to latest
|
||||
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||
```
|
||||
|
||||
### Corrupted Migration State
|
||||
|
||||
If `alembic_version` table is corrupted:
|
||||
|
||||
```bash
|
||||
# Manually fix via SQL
|
||||
sqlite3 /path/to/tokens.db
|
||||
> DELETE FROM alembic_version;
|
||||
> INSERT INTO alembic_version (version_num) VALUES ('001');
|
||||
> .quit
|
||||
|
||||
# Verify and upgrade
|
||||
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
|
||||
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
### Pre-Deployment
|
||||
|
||||
```bash
|
||||
# Run migrations in test environment
|
||||
export TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||
uv run nextcloud-mcp-server db upgrade
|
||||
|
||||
# Verify current version
|
||||
uv run nextcloud-mcp-server db current
|
||||
```
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`.
|
||||
|
||||
### Rollback Plan
|
||||
|
||||
1. Stop application
|
||||
2. Backup database: `cp tokens.db tokens.db.backup`
|
||||
3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX`
|
||||
4. Deploy previous application version
|
||||
5. Restart application
|
||||
|
||||
## References
|
||||
|
||||
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
|
||||
- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html)
|
||||
- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001)
|
||||
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 244 KiB |
|
After Width: | Height: | Size: 483 KiB |
@@ -14,100 +14,10 @@ Before running the server:
|
||||
|
||||
## Quick Start
|
||||
|
||||
Load your environment variables and start the server:
|
||||
Start the server using Docker:
|
||||
|
||||
```bash
|
||||
# Load environment variables from .env
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Start the server
|
||||
uv run nextcloud-mcp-server
|
||||
```
|
||||
|
||||
The server will start on `http://127.0.0.1:8000` by default.
|
||||
|
||||
---
|
||||
|
||||
## Running Locally
|
||||
|
||||
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
|
||||
|
||||
The CLI provides a simple interface with built-in defaults:
|
||||
|
||||
#### OAuth Mode
|
||||
|
||||
```bash
|
||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Explicitly force OAuth mode
|
||||
uv run nextcloud-mcp-server --oauth
|
||||
|
||||
# OAuth with custom host and port
|
||||
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
|
||||
|
||||
# OAuth with pre-configured client
|
||||
uv run nextcloud-mcp-server --oauth \
|
||||
--oauth-client-id abc123 \
|
||||
--oauth-client-secret xyz789
|
||||
|
||||
# OAuth with specific apps only
|
||||
uv run nextcloud-mcp-server --oauth \
|
||||
--enable-app notes \
|
||||
--enable-app calendar
|
||||
```
|
||||
|
||||
#### BasicAuth Mode (Legacy)
|
||||
|
||||
```bash
|
||||
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
|
||||
uv run nextcloud-mcp-server
|
||||
|
||||
# Explicitly force BasicAuth mode
|
||||
uv run nextcloud-mcp-server --no-oauth
|
||||
|
||||
# BasicAuth with specific apps
|
||||
uv run nextcloud-mcp-server --no-oauth \
|
||||
--enable-app notes \
|
||||
--enable-app webdav
|
||||
```
|
||||
|
||||
### Method 2: Using uvicorn
|
||||
|
||||
For more control over server options (workers, reload, etc.):
|
||||
|
||||
```bash
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run with uvicorn
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--reload # Enable auto-reload for development
|
||||
```
|
||||
|
||||
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
|
||||
|
||||
### Method 3: Using Python Module
|
||||
|
||||
```bash
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run as Python module
|
||||
python -m nextcloud_mcp_server.app --oauth --port 8000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running with Docker
|
||||
|
||||
### Basic Docker Run
|
||||
|
||||
```bash
|
||||
# OAuth mode
|
||||
# OAuth mode (recommended)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
@@ -116,11 +26,56 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
```
|
||||
|
||||
### Docker with Persistent OAuth Storage
|
||||
The server will start on `http://127.0.0.1:8000` by default.
|
||||
|
||||
---
|
||||
|
||||
## Running with Docker
|
||||
|
||||
### Basic Docker Run
|
||||
|
||||
#### OAuth Mode (Recommended)
|
||||
|
||||
```bash
|
||||
# OAuth with auto-registration
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# OAuth with custom port
|
||||
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# OAuth with pre-configured client
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
-e NEXTCLOUD_OIDC_CLIENT_ID=abc123 \
|
||||
-e NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789 \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# OAuth with specific apps only
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--enable-app notes --enable-app calendar
|
||||
```
|
||||
|
||||
#### BasicAuth Mode (Legacy)
|
||||
|
||||
```bash
|
||||
# BasicAuth (requires NEXTCLOUD_USERNAME/PASSWORD in .env)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
|
||||
# BasicAuth with specific apps
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
|
||||
--enable-app notes --enable-app webdav
|
||||
```
|
||||
|
||||
### Docker with Persistent Token Storage
|
||||
|
||||
```bash
|
||||
# Mount volume for persistent OAuth token storage
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env \
|
||||
-v $(pwd)/.oauth:/app/.oauth \
|
||||
-v $(pwd)/data:/app/data \
|
||||
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
@@ -140,7 +95,7 @@ services:
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./oauth-storage:/app/.oauth
|
||||
- ./data:/app/data # Persistent token storage
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
@@ -168,30 +123,39 @@ docker-compose down
|
||||
|
||||
```bash
|
||||
# Bind to all interfaces (accessible from network)
|
||||
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
|
||||
docker run -p 0.0.0.0:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# Bind to localhost only (default, more secure)
|
||||
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# Use a different port
|
||||
uv run nextcloud-mcp-server --port 8080
|
||||
# Use a different port (map host port 8080 to container port 8000)
|
||||
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
```
|
||||
|
||||
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
|
||||
**Security Note:** Binding to `0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
|
||||
|
||||
### Transport Protocols
|
||||
|
||||
The server supports multiple MCP transport protocols:
|
||||
|
||||
```bash
|
||||
# Streamable HTTP (recommended)
|
||||
uv run nextcloud-mcp-server --transport streamable-http
|
||||
# Streamable HTTP (default, recommended)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--transport streamable-http
|
||||
|
||||
# SSE - Server-Sent Events (default, deprecated)
|
||||
uv run nextcloud-mcp-server --transport sse
|
||||
# SSE - Server-Sent Events (deprecated)
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--transport sse
|
||||
|
||||
# HTTP
|
||||
uv run nextcloud-mcp-server --transport http
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--transport http
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
@@ -201,10 +165,14 @@ uv run nextcloud-mcp-server --transport http
|
||||
|
||||
```bash
|
||||
# Set log level (critical, error, warning, info, debug, trace)
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--log-level debug
|
||||
|
||||
# Production: use warning or error
|
||||
uv run nextcloud-mcp-server --log-level warning
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--log-level warning
|
||||
```
|
||||
|
||||
### Selective App Enablement
|
||||
@@ -212,22 +180,26 @@ uv run nextcloud-mcp-server --log-level warning
|
||||
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
|
||||
|
||||
```bash
|
||||
# Available apps: notes, tables, webdav, calendar, contacts, deck
|
||||
# Available apps: notes, tables, webdav, calendar, contacts, cookbook, deck
|
||||
|
||||
# Enable all apps (default)
|
||||
uv run nextcloud-mcp-server
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
|
||||
|
||||
# Enable only Notes
|
||||
uv run nextcloud-mcp-server --enable-app notes
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--enable-app notes
|
||||
|
||||
# Enable multiple apps
|
||||
uv run nextcloud-mcp-server \
|
||||
--enable-app notes \
|
||||
--enable-app calendar \
|
||||
--enable-app contacts
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--enable-app notes --enable-app calendar --enable-app contacts
|
||||
|
||||
# Enable only WebDAV for file operations
|
||||
uv run nextcloud-mcp-server --enable-app webdav
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--enable-app webdav
|
||||
```
|
||||
|
||||
**Use cases:**
|
||||
@@ -240,24 +212,68 @@ uv run nextcloud-mcp-server --enable-app webdav
|
||||
|
||||
## Development Mode
|
||||
|
||||
For active development with auto-reload:
|
||||
### Running for Development
|
||||
|
||||
For active development with auto-reload, mount your source code as a volume:
|
||||
|
||||
```bash
|
||||
# Using uvicorn with reload
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--reload \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
# Development mode with source code mounted
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
-v $(pwd):/app \
|
||||
-v $(pwd)/data:/app/data \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--log-level debug
|
||||
```
|
||||
|
||||
Or use the CLI with reload flag:
|
||||
For local development without Docker:
|
||||
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --reload --log-level debug
|
||||
# Load environment variables
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
|
||||
# Run the server with auto-reload
|
||||
uv run nextcloud-mcp-server run --oauth --log-level debug
|
||||
```
|
||||
|
||||
### CLI Subcommands
|
||||
|
||||
The `nextcloud-mcp-server` CLI has two main subcommands:
|
||||
|
||||
1. **`run`** - Start the MCP server (default command in Docker)
|
||||
```bash
|
||||
uv run nextcloud-mcp-server run --oauth --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
2. **`db`** - Database migration management (Alembic)
|
||||
```bash
|
||||
# Show current migration revision
|
||||
uv run nextcloud-mcp-server db current
|
||||
|
||||
# Upgrade to latest migration
|
||||
uv run nextcloud-mcp-server db upgrade
|
||||
|
||||
# Show migration history
|
||||
uv run nextcloud-mcp-server db history
|
||||
|
||||
# Create new migration (developers only)
|
||||
uv run nextcloud-mcp-server db migrate "description of changes"
|
||||
```
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Token storage uses **Alembic** for schema management:
|
||||
|
||||
- **Automatic migrations**: Database is upgraded automatically on server startup
|
||||
- **Backward compatibility**: Pre-Alembic databases are automatically stamped with the initial revision
|
||||
- **Migration files**: Located in `alembic/versions/`
|
||||
- **For developers**: When changing the schema:
|
||||
1. Create a migration: `uv run nextcloud-mcp-server db migrate "add new column"`
|
||||
2. Edit the generated file in `alembic/versions/` to add SQL statements
|
||||
3. Test upgrade: `uv run nextcloud-mcp-server db upgrade`
|
||||
4. Test downgrade: `uv run nextcloud-mcp-server db downgrade`
|
||||
|
||||
See [Database Migrations Guide](database-migrations.md) for detailed information.
|
||||
|
||||
---
|
||||
|
||||
## Connecting to the Server
|
||||
@@ -266,15 +282,15 @@ uv run nextcloud-mcp-server --reload --log-level debug
|
||||
|
||||
MCP Inspector is a browser-based tool for testing MCP servers:
|
||||
|
||||
```bash
|
||||
# Start MCP Inspector
|
||||
uv run mcp dev
|
||||
|
||||
# In the browser:
|
||||
# 1. Enter server URL: http://localhost:8000
|
||||
# 2. Complete OAuth flow (if using OAuth)
|
||||
# 3. Explore tools and resources
|
||||
```
|
||||
1. Start your MCP server using Docker (see above)
|
||||
2. Start MCP Inspector:
|
||||
```bash
|
||||
npx @modelcontextprotocol/inspector
|
||||
```
|
||||
3. In the browser:
|
||||
- Enter server URL: `http://localhost:8000`
|
||||
- Complete OAuth flow (if using OAuth)
|
||||
- Explore tools and resources
|
||||
|
||||
### Using MCP Clients
|
||||
|
||||
@@ -322,48 +338,13 @@ INFO Initializing Nextcloud client with BasicAuth
|
||||
|
||||
### Running as a Background Service
|
||||
|
||||
#### Using systemd (Linux)
|
||||
|
||||
Create `/etc/systemd/system/nextcloud-mcp.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Nextcloud MCP Server
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your-user
|
||||
WorkingDirectory=/path/to/nextcloud-mcp-server
|
||||
EnvironmentFile=/path/to/.env
|
||||
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable nextcloud-mcp
|
||||
sudo systemctl start nextcloud-mcp
|
||||
sudo systemctl status nextcloud-mcp
|
||||
```
|
||||
|
||||
#### Using Docker Compose
|
||||
|
||||
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
|
||||
Use Docker Compose with `restart: unless-stopped` (see [Docker Compose section](#docker-compose) above).
|
||||
|
||||
### Monitoring Logs
|
||||
|
||||
```bash
|
||||
# Local installation with systemd
|
||||
sudo journalctl -u nextcloud-mcp -f
|
||||
|
||||
# Docker
|
||||
# Docker (find container name first)
|
||||
docker ps
|
||||
docker logs -f <container-name>
|
||||
|
||||
# Docker Compose
|
||||
@@ -374,35 +355,38 @@ docker-compose logs -f mcp
|
||||
|
||||
## Performance Tuning
|
||||
|
||||
### Multiple Workers
|
||||
|
||||
For production deployments with higher load:
|
||||
|
||||
```bash
|
||||
# Using CLI (if supported)
|
||||
uv run nextcloud-mcp-server --workers 4
|
||||
|
||||
# Using uvicorn
|
||||
uv run uvicorn nextcloud_mcp_server.app:get_app \
|
||||
--factory \
|
||||
--workers 4 \
|
||||
--host 0.0.0.0 \
|
||||
--port 8000
|
||||
```
|
||||
|
||||
### Production Settings
|
||||
|
||||
```bash
|
||||
# Recommended production configuration
|
||||
uv run nextcloud-mcp-server \
|
||||
--oauth \
|
||||
--host 127.0.0.1 \
|
||||
--port 8000 \
|
||||
--log-level warning \
|
||||
--transport streamable-http \
|
||||
--workers 2
|
||||
For production deployments, use Docker Compose with the recommended settings:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
mcp:
|
||||
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||
command: --oauth --log-level warning --transport streamable-http
|
||||
ports:
|
||||
- "127.0.0.1:8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
```
|
||||
|
||||
### Scaling with Multiple Replicas
|
||||
|
||||
For higher load, use Docker Swarm or Kubernetes. See the [Helm Chart](../helm/) for Kubernetes deployments.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
@@ -411,12 +395,18 @@ uv run nextcloud-mcp-server \
|
||||
|
||||
Check logs for errors:
|
||||
```bash
|
||||
uv run nextcloud-mcp-server --log-level debug
|
||||
# View container logs
|
||||
docker logs <container-name>
|
||||
|
||||
# Or run with debug logging
|
||||
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
|
||||
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
|
||||
--log-level debug
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
|
||||
- Port already in use - Try a different port with `--port`
|
||||
- Environment variables not loaded - Check your `.env` file
|
||||
- Port already in use - Use a different host port (e.g., `-p 127.0.0.1:8080:8000`)
|
||||
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
|
||||
|
||||
### Can't connect to server
|
||||
|
||||
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
|
||||
> [!IMPORTANT]
|
||||
> **Status: Experimental**
|
||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
|
||||
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
|
||||
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
||||
> - RAG answer generation requires MCP client sampling support
|
||||
|
||||
@@ -39,9 +39,9 @@ Semantic search enables:
|
||||
|
||||
### Current Support
|
||||
|
||||
- **Supported Apps**: Notes (fully implemented)
|
||||
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
|
||||
- **Architecture**: Multi-app plugin system ready, awaiting implementation
|
||||
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
|
||||
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
|
||||
- **Architecture**: Multi-app plugin system ready for additional apps
|
||||
|
||||
## System Components
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Vector Sync UI Guide
|
||||
|
||||
This guide covers the browser-based interface for the Nextcloud MCP Server's semantic search and vector synchronization features.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vector Sync UI (`/app`) provides an interactive interface to test semantic search queries and visualize results from your Nextcloud documents. It exposes the same retrieval capabilities that LLMs use in Retrieval-Augmented Generation (RAG) workflows, powered by Alpine.js for reactive state, htmx for dynamic updates, and Plotly.js for 3D visualization.
|
||||
|
||||
**Supported Apps**: Notes, Files (text/PDF), Calendar (events/tasks), Contacts (CardDAV), and Deck are indexed and searchable.
|
||||
|
||||
## Accessing the UI
|
||||
|
||||
Navigate to `/app` after authentication:
|
||||
- **BasicAuth mode**: `http://localhost:8000/app` (uses credentials from environment)
|
||||
- **OAuth mode**: `http://localhost:8000/app` (redirects to login if not authenticated)
|
||||
|
||||
## Tabs
|
||||
|
||||
### Welcome Page
|
||||
|
||||
Landing page that introduces semantic search and RAG workflows. Shows authentication status, explains how vector embeddings work, and provides feature navigation. Adapts content based on whether `VECTOR_SYNC_ENABLED=true`.
|
||||
|
||||
### User Info
|
||||
|
||||
Displays authentication details and session information:
|
||||
- **BasicAuth**: Username, mode badge, Nextcloud host
|
||||
- **OAuth**: Username, session ID (truncated), background access status, IdP profile, revocation option
|
||||
|
||||
### Vector Sync Status
|
||||
|
||||
Real-time monitoring of document indexing:
|
||||
- **Indexed Documents**: Total chunks stored in Qdrant vector database (immediately searchable)
|
||||
- **Pending Documents**: Queue awaiting embedding processing
|
||||
- **Status**: "✓ Idle" (green) when up-to-date, "⟳ Syncing" (orange) during processing
|
||||
|
||||
Auto-refreshes every 10 seconds via htmx. Check this tab after adding content to verify indexing completion.
|
||||
|
||||
### Vector Visualization
|
||||
|
||||
Interactive search interface with 3D PCA plot of semantic space.
|
||||
|
||||
**Search Controls**:
|
||||
- **Query**: Natural language search (e.g., "health benefits of coffee")
|
||||
- **Algorithm**: Semantic (Dense) for pure vector search, or BM25 Hybrid (default) combining vectors + keywords
|
||||
- **Fusion** (Hybrid only): RRF (Reciprocal Rank Fusion) or DBSF (Distribution-Based Score Fusion)
|
||||
- **Advanced**: Filter by document type, adjust score threshold (0.0-1.0), set result limit (max 100)
|
||||
|
||||
**3D Visualization**:
|
||||
|
||||
The plot uses Principal Component Analysis (PCA) to reduce 768-dimensional embeddings to 3D. Documents are positioned by semantic similarity with the query point shown in red. Point size and opacity indicate relevance, and the Viridis color scale shows relative scores (yellow = highest match).
|
||||
|
||||
**Critical Fix**: Vectors are L2-normalized before PCA to match Qdrant's cosine distance, ensuring query points position accurately near similar documents. Without normalization, magnitude differences cause misleading spatial separation.
|
||||
|
||||
**Results List**:
|
||||
|
||||
Each result shows document title (clickable link to Nextcloud), excerpt, raw score, relative percentage, and document type. Click "Show Chunk" to view the matched text segment with surrounding context (up to 500 characters before/after).
|
||||
|
||||
## Configuration
|
||||
|
||||
**Required**:
|
||||
```bash
|
||||
VECTOR_SYNC_ENABLED=true
|
||||
```
|
||||
|
||||
**Optional** (for browser-accessible links):
|
||||
```bash
|
||||
NEXTCLOUD_PUBLIC_ISSUER_URL=https://your-public-nextcloud-url.com
|
||||
```
|
||||
|
||||
**Admin Access**: Webhooks tab only visible to Nextcloud admins (verified via Provisioning API).
|
||||
|
||||
## Use Cases
|
||||
|
||||
**Testing Search Queries**: Preview results before they reach LLMs in RAG workflows. Compare semantic vs. hybrid algorithms, verify relevance scores, and validate that correct documents are retrieved. Use chunk context to see exactly which text segments match and why unexpected documents appear.
|
||||
|
||||
**Monitoring Indexing**: Track real-time progress after creating or modifying documents. Check if the queue is backing up (high pending count) or confirm the system is idle after bulk imports. Verify documents become searchable immediately after indexing completes.
|
||||
|
||||
**Algorithm Comparison**: Pure semantic search excels at conceptual queries and synonyms. BM25 hybrid combines semantic understanding with precise keyword matching for better accuracy on specific terms. Experiment with RRF vs. DBSF fusion for different score distributions.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Vector Sync Tab Not Visible**: Set `VECTOR_SYNC_ENABLED=true` and restart the server.
|
||||
|
||||
**No Search Results**: Check Vector Sync Status to confirm documents are indexed (not just pending). Try broader queries or lower the score threshold in Advanced options. Initial indexing may take time depending on document volume.
|
||||
|
||||
**Links to Nextcloud Apps Not Working**: Set `NEXTCLOUD_PUBLIC_ISSUER_URL` to your browser-accessible Nextcloud URL for correct link generation.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Configuration Guide](../configuration.md) - Environment variables and settings
|
||||
- [Authentication Modes](../authentication.md) - BasicAuth vs OAuth setup
|
||||
- [Installation Guide](../installation.md) - Getting started
|
||||
- [ADR-008: MCP Sampling for RAG](../ADR-008-mcp-sampling-for-rag.md) - Technical details on RAG workflows
|
||||
@@ -51,6 +51,11 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
|
||||
NEXTCLOUD_USERNAME=
|
||||
NEXTCLOUD_PASSWORD=
|
||||
|
||||
# Cookie security (browser UI)
|
||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||
# Set explicitly for non-standard setups
|
||||
#COOKIE_SECURE=true
|
||||
|
||||
# ============================================
|
||||
# Document Processing Configuration
|
||||
# ============================================
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Alembic environment configuration for nextcloud-mcp-server.
|
||||
|
||||
This module configures how Alembic runs database migrations for the
|
||||
token storage database. It supports both online and offline migration modes.
|
||||
|
||||
Uses anyio for async operations, consistent with the project's async patterns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger("alembic.env")
|
||||
|
||||
# This is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Update script location to point to package location
|
||||
# This allows alembic to find migrations when installed in site-packages
|
||||
script_location = Path(__file__).parent
|
||||
config.set_main_option("script_location", str(script_location))
|
||||
|
||||
# We don't use SQLAlchemy models, so target_metadata is None
|
||||
# Migrations will be written manually using op.execute() for raw SQL
|
||||
target_metadata = None
|
||||
|
||||
|
||||
def get_database_url() -> str:
|
||||
"""
|
||||
Get the database URL from Alembic config or environment.
|
||||
|
||||
The URL can be set in alembic.ini or passed via -x database_url=...
|
||||
when running Alembic commands.
|
||||
|
||||
Returns:
|
||||
Database URL (SQLite URL format)
|
||||
"""
|
||||
# Check if URL is passed via -x database_url=...
|
||||
url = context.get_x_argument(as_dictionary=True).get("database_url")
|
||||
|
||||
if not url:
|
||||
# Fall back to alembic.ini configuration
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
|
||||
if not url:
|
||||
# Default to /app/data/tokens.db for Docker deployments
|
||||
db_path = Path("/app/data/tokens.db")
|
||||
url = f"sqlite+aiosqlite:///{db_path}"
|
||||
logger.warning(
|
||||
f"No database URL configured, using default: {url}. "
|
||||
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..."
|
||||
)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL and not an Engine,
|
||||
though an Engine is acceptable here as well. By skipping the
|
||||
Engine creation we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
This mode is useful for generating SQL scripts without database access.
|
||||
"""
|
||||
url = get_database_url()
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Execute migrations within a database connection."""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in 'online' mode with async support.
|
||||
|
||||
In this scenario we create an async Engine and associate
|
||||
a connection with the context.
|
||||
"""
|
||||
# Get database URL and update config
|
||||
url = get_database_url()
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
# Create async engine
|
||||
connectable = async_engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool, # Don't pool connections for migrations
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
This function is called from storage.py's initialize() method via
|
||||
anyio.to_thread.run_sync(), so it always runs in a worker thread
|
||||
with its own event loop. We can safely use anyio.run() here.
|
||||
"""
|
||||
anyio.run(run_async_migrations)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Initial schema for token storage database
|
||||
|
||||
This migration creates the initial database schema including:
|
||||
- refresh_tokens: OAuth refresh tokens and user profiles
|
||||
- audit_logs: Audit trail for security events
|
||||
- oauth_clients: OAuth client credentials (DCR)
|
||||
- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent)
|
||||
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
|
||||
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
|
||||
|
||||
Revision ID: 001
|
||||
Revises:
|
||||
Create Date: 2025-12-17 22:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create initial database schema."""
|
||||
|
||||
# Refresh tokens table (OAuth mode only, for background jobs)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid',
|
||||
token_audience TEXT DEFAULT 'nextcloud',
|
||||
provisioned_at INTEGER,
|
||||
provisioning_client_id TEXT,
|
||||
scopes TEXT,
|
||||
-- Browser session profile cache
|
||||
user_profile TEXT,
|
||||
profile_cached_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Audit logs table (both OAuth and BasicAuth modes)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
auth_method TEXT,
|
||||
hostname TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index on audit logs for efficient queries
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
|
||||
ON audit_logs(user_id, timestamp)
|
||||
"""
|
||||
)
|
||||
|
||||
# OAuth client credentials storage (OAuth mode only)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id INTEGER PRIMARY KEY,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
encrypted_client_secret BLOB NOT NULL,
|
||||
client_id_issued_at INTEGER NOT NULL,
|
||||
client_secret_expires_at INTEGER NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
encrypted_registration_access_token BLOB,
|
||||
registration_client_uri TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# OAuth flow sessions (ADR-004 Progressive Consent)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
client_redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
mcp_authorization_code TEXT UNIQUE,
|
||||
idp_access_token TEXT,
|
||||
idp_refresh_token TEXT,
|
||||
user_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid',
|
||||
requested_scopes TEXT,
|
||||
granted_scopes TEXT,
|
||||
is_provisioning BOOLEAN DEFAULT FALSE
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Index for MCP authorization code lookups
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code
|
||||
ON oauth_sessions(mcp_authorization_code)
|
||||
"""
|
||||
)
|
||||
|
||||
# Legacy schema version tracking table
|
||||
# NOTE: This is deprecated in favor of Alembic's alembic_version table
|
||||
# Kept for backward compatibility with pre-Alembic databases
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Registered webhooks tracking (both BasicAuth and OAuth modes)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS registered_webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL UNIQUE,
|
||||
preset_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Indexes for efficient webhook queries
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_preset
|
||||
ON registered_webhooks(preset_id)
|
||||
"""
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_created
|
||||
ON registered_webhooks(created_at)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop all tables and indexes.
|
||||
|
||||
WARNING: This will destroy all data in the database!
|
||||
Use with extreme caution.
|
||||
"""
|
||||
|
||||
# Drop indexes first
|
||||
op.execute("DROP INDEX IF EXISTS idx_webhooks_created")
|
||||
op.execute("DROP INDEX IF EXISTS idx_webhooks_preset")
|
||||
op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code")
|
||||
op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp")
|
||||
|
||||
# Drop tables
|
||||
op.execute("DROP TABLE IF EXISTS registered_webhooks")
|
||||
op.execute("DROP TABLE IF EXISTS schema_version")
|
||||
op.execute("DROP TABLE IF EXISTS oauth_sessions")
|
||||
op.execute("DROP TABLE IF EXISTS oauth_clients")
|
||||
op.execute("DROP TABLE IF EXISTS audit_logs")
|
||||
op.execute("DROP TABLE IF EXISTS refresh_tokens")
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Management API for Nextcloud MCP Server.
|
||||
|
||||
Provides REST endpoints for the Nextcloud PHP app to query server status,
|
||||
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
|
||||
authentication via the UnifiedTokenVerifier.
|
||||
"""
|
||||
@@ -24,6 +24,26 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _should_use_secure_cookies() -> bool:
|
||||
"""Determine if cookies should have secure flag.
|
||||
|
||||
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
|
||||
|
||||
Returns:
|
||||
True if cookies should be secure (HTTPS), False otherwise
|
||||
"""
|
||||
# Explicit configuration takes precedence
|
||||
explicit = os.getenv("COOKIE_SECURE", "").lower()
|
||||
if explicit == "true":
|
||||
return True
|
||||
if explicit == "false":
|
||||
return False
|
||||
|
||||
# Auto-detect from NEXTCLOUD_HOST protocol
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
|
||||
return nextcloud_host.startswith("https://")
|
||||
|
||||
|
||||
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"""Browser OAuth login endpoint - redirects to IdP for authentication.
|
||||
|
||||
@@ -50,6 +70,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
|
||||
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
|
||||
|
||||
# Get redirect URL from query params (default to /app)
|
||||
next_url = request.query_params.get("next", "/app")
|
||||
logger.info(f"oauth_login - next_url: {next_url}")
|
||||
|
||||
# Generate state for CSRF protection
|
||||
state = secrets.token_urlsafe(32)
|
||||
|
||||
@@ -71,7 +95,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
await storage.store_oauth_session(
|
||||
session_id=state, # Use state as session ID
|
||||
client_id="browser-ui",
|
||||
client_redirect_uri="/app",
|
||||
client_redirect_uri=next_url, # Store the redirect URL for after auth
|
||||
state=state,
|
||||
code_challenge=code_challenge,
|
||||
code_challenge_method="S256",
|
||||
@@ -85,6 +109,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
if not oauth_client.authorization_endpoint:
|
||||
await oauth_client.discover()
|
||||
|
||||
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
|
||||
nextcloud_resource_uri = oauth_config.get(
|
||||
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_client.client_id,
|
||||
"redirect_uri": callback_uri,
|
||||
@@ -94,6 +123,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
||||
}
|
||||
|
||||
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
|
||||
@@ -131,6 +161,11 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
|
||||
)
|
||||
|
||||
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
|
||||
nextcloud_resource_uri = oauth_config.get(
|
||||
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
|
||||
)
|
||||
|
||||
idp_params = {
|
||||
"client_id": oauth_config["client_id"],
|
||||
"redirect_uri": callback_uri,
|
||||
@@ -140,6 +175,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "consent", # Ensure refresh token
|
||||
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
|
||||
}
|
||||
|
||||
# Debug: Log full parameters
|
||||
@@ -214,12 +250,15 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_config = oauth_ctx["config"]
|
||||
|
||||
# Retrieve code_verifier from session storage (PKCE required for all modes)
|
||||
# Retrieve code_verifier and redirect URL from session storage
|
||||
code_verifier = ""
|
||||
next_url = "/app" # Default redirect
|
||||
oauth_session = await storage.get_oauth_session(state)
|
||||
if oauth_session:
|
||||
# code_verifier was stored in mcp_authorization_code field
|
||||
code_verifier = oauth_session.get("mcp_authorization_code", "")
|
||||
# next_url was stored in client_redirect_uri field
|
||||
next_url = oauth_session.get("client_redirect_uri", "/app")
|
||||
# Clean up the temporary session
|
||||
# Note: We don't have delete_oauth_session method, but it will expire after TTL
|
||||
|
||||
@@ -262,6 +301,25 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
discovery = response.json()
|
||||
token_endpoint = discovery["token_endpoint"]
|
||||
|
||||
# Rewrite token_endpoint from public URL to internal Docker URL
|
||||
# Discovery document returns public URLs (e.g., http://localhost:8080/...)
|
||||
# but server-side requests must use internal Docker network (e.g., http://app:80/...)
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if public_issuer:
|
||||
from urllib.parse import urlparse as parse_url
|
||||
|
||||
internal_host = oauth_config["nextcloud_host"]
|
||||
internal_parsed = parse_url(internal_host)
|
||||
token_parsed = parse_url(token_endpoint)
|
||||
public_parsed = parse_url(public_issuer)
|
||||
|
||||
if token_parsed.hostname == public_parsed.hostname:
|
||||
# Replace public URL with internal Docker URL
|
||||
token_endpoint = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
||||
logger.info(
|
||||
f"Rewrote token endpoint to internal URL: {token_endpoint}"
|
||||
)
|
||||
|
||||
token_params = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
@@ -338,16 +396,35 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
user_id = f"user-{secrets.token_hex(8)}"
|
||||
username = "unknown"
|
||||
|
||||
# Calculate refresh token expiration from token response
|
||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||
refresh_expires_at = None
|
||||
if refresh_expires_in:
|
||||
import time
|
||||
|
||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||
logger.info(
|
||||
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
|
||||
)
|
||||
|
||||
# Extract granted scopes
|
||||
granted_scopes = (
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
# Store refresh token (for background jobs ONLY)
|
||||
if refresh_token:
|
||||
logger.info(f"Storing refresh token for user_id: {user_id}")
|
||||
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
|
||||
logger.info(f" Granted scopes: {granted_scopes}")
|
||||
logger.info(f" Expires at: {refresh_expires_at}")
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=refresh_token,
|
||||
expires_at=None,
|
||||
expires_at=refresh_expires_at,
|
||||
flow_type="browser", # Browser-based login flow
|
||||
provisioning_client_id=state, # Store state for unified session lookup
|
||||
scopes=granted_scopes,
|
||||
)
|
||||
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
|
||||
logger.info(
|
||||
@@ -383,13 +460,14 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
||||
# Continue anyway - profile cache is optional for browser UI
|
||||
|
||||
# Create response and set session cookie
|
||||
response = RedirectResponse("/app", status_code=302)
|
||||
# Redirect to stored next_url (from OAuth session) or /app as default
|
||||
response = RedirectResponse(next_url, status_code=302)
|
||||
response.set_cookie(
|
||||
key="mcp_session",
|
||||
value=user_id,
|
||||
max_age=86400 * 30, # 30 days
|
||||
httponly=True,
|
||||
secure=False, # Set to True in production with HTTPS
|
||||
secure=_should_use_secure_cookies(),
|
||||
samesite="lax",
|
||||
)
|
||||
|
||||
|
||||
@@ -517,12 +517,23 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_data.get("scope", "").split() if token_data.get("scope") else None
|
||||
)
|
||||
|
||||
# Calculate refresh token expiration from token response
|
||||
refresh_expires_in = token_data.get("refresh_expires_in")
|
||||
refresh_expires_at = None
|
||||
if refresh_expires_in:
|
||||
import time
|
||||
|
||||
refresh_expires_at = int(time.time()) + refresh_expires_in
|
||||
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
|
||||
logger.info(f" refresh_expires_at: {refresh_expires_at}")
|
||||
|
||||
logger.info("Storing refresh token:")
|
||||
logger.info(f" user_id: {user_id}")
|
||||
logger.info(" flow_type: flow2")
|
||||
logger.info(" token_audience: nextcloud")
|
||||
logger.info(f" provisioning_client_id: {state[:16]}...")
|
||||
logger.info(f" scopes: {granted_scopes}")
|
||||
logger.info(f" expires_at: {refresh_expires_at}")
|
||||
|
||||
await storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
@@ -531,7 +542,7 @@ async def oauth_callback_nextcloud(request: Request):
|
||||
token_audience="nextcloud",
|
||||
provisioning_client_id=state, # Store which client initiated provisioning
|
||||
scopes=granted_scopes,
|
||||
expires_at=None, # Refresh tokens typically don't expire
|
||||
expires_at=refresh_expires_at,
|
||||
)
|
||||
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -0,0 +1,219 @@
|
||||
.viz-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.viz-card {
|
||||
background: var(--color-main-background);
|
||||
border-radius: 0;
|
||||
padding: 16px;
|
||||
box-shadow: none;
|
||||
}
|
||||
.viz-controls-card {
|
||||
flex: 0 0 auto;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.viz-controls-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.viz-controls-grid {
|
||||
grid-template-columns: 2fr 1.5fr 1.5fr auto auto;
|
||||
}
|
||||
}
|
||||
.viz-control-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.viz-control-group label {
|
||||
font-weight: 500;
|
||||
color: var(--color-main-text);
|
||||
font-size: 13px;
|
||||
}
|
||||
.viz-control-group input[type="text"],
|
||||
.viz-control-group input[type="number"],
|
||||
.viz-control-group select {
|
||||
width: 100%;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--color-border-dark);
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 14px;
|
||||
background: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.viz-control-group input:focus,
|
||||
.viz-control-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary-element);
|
||||
}
|
||||
.viz-control-group input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
.viz-control-group select[multiple] {
|
||||
min-height: 100px;
|
||||
}
|
||||
.viz-weight-display {
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}
|
||||
.viz-btn {
|
||||
background: var(--color-primary-element);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viz-btn:hover {
|
||||
background: #0052a3;
|
||||
}
|
||||
.viz-btn-secondary {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 7px 16px;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.viz-btn-secondary:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
.viz-card-plot {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 500px;
|
||||
height: 600px;
|
||||
/* Remove horizontal padding to extend to full viewport width */
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
margin-left: -16px;
|
||||
margin-right: -16px;
|
||||
}
|
||||
#viz-plot-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
#viz-plot {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.viz-loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}
|
||||
.viz-loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
color: #666;
|
||||
}
|
||||
.viz-no-results {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}
|
||||
.viz-advanced-section {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.viz-info-box {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 3px solid var(--color-primary-element);
|
||||
padding: 10px 12px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 13px;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.chunk-toggle-btn {
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 4px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.chunk-toggle-btn:hover {
|
||||
background: #5a6268;
|
||||
}
|
||||
.chunk-context {
|
||||
background: var(--color-background-hover);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 12px;
|
||||
margin-top: 8px;
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.chunk-text {
|
||||
color: var(--color-text-maxcontrast);
|
||||
}
|
||||
.chunk-matched {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--border-radius);
|
||||
font-weight: 500;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
.chunk-ellipsis {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* PDF highlighted image styles */
|
||||
.chunk-image-container {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.chunk-image-header {
|
||||
background: var(--color-background-dark);
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-maxcontrast);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-family: var(--font-face);
|
||||
}
|
||||
.chunk-highlighted-image {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
.chunk-highlighted-image:hover {
|
||||
opacity: 0.95;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
// Initialize vizApp for vector visualization
|
||||
function vizApp() {
|
||||
return {
|
||||
query: '',
|
||||
algorithm: 'bm25_hybrid',
|
||||
fusion: 'rrf',
|
||||
showAdvanced: false,
|
||||
showQueryPoint: true,
|
||||
docTypes: [''],
|
||||
limit: 50,
|
||||
scoreThreshold: 0.0,
|
||||
loading: false,
|
||||
results: [],
|
||||
coordinates: null,
|
||||
queryCoords: null,
|
||||
expandedChunks: {},
|
||||
chunkLoading: {},
|
||||
|
||||
init() {
|
||||
// Set up window resize listener to resize plot
|
||||
window.addEventListener('resize', () => {
|
||||
if (this.coordinates && this.results.length > 0) {
|
||||
Plotly.Plots.resize('viz-plot');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async executeSearch() {
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
query: this.query,
|
||||
algorithm: this.algorithm,
|
||||
limit: this.limit,
|
||||
score_threshold: this.scoreThreshold,
|
||||
});
|
||||
|
||||
if (this.algorithm === 'bm25_hybrid') {
|
||||
params.append('fusion', this.fusion);
|
||||
}
|
||||
|
||||
const selectedTypes = this.docTypes.filter(t => t !== '');
|
||||
if (selectedTypes.length > 0) {
|
||||
params.append('doc_types', selectedTypes.join(','));
|
||||
}
|
||||
|
||||
const response = await fetch(`/app/vector-viz/search?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.results = data.results;
|
||||
this.coordinates = data.coordinates_3d;
|
||||
this.queryCoords = data.query_coords;
|
||||
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
||||
} else {
|
||||
alert('Search failed: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error: ' + error.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
updatePlot() {
|
||||
// Toggle query point visibility without recreating the plot
|
||||
// This preserves camera position naturally since layout is untouched
|
||||
if (this.coordinates && this.queryCoords && this.results.length > 0) {
|
||||
const plotDiv = document.getElementById('viz-plot');
|
||||
|
||||
// If plot exists, just toggle the query trace visibility
|
||||
if (plotDiv && plotDiv.data && plotDiv.data.length >= 2) {
|
||||
// Trace index 1 is the query point
|
||||
Plotly.restyle('viz-plot', { visible: this.showQueryPoint }, [1]);
|
||||
} else {
|
||||
// Plot doesn't exist yet, render it
|
||||
this.renderPlot(this.coordinates, this.queryCoords, this.results);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
renderPlot(coordinates, queryCoords, results) {
|
||||
// Get container dimensions before creating layout
|
||||
const container = document.getElementById('viz-plot-container');
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight;
|
||||
|
||||
const scores = results.map(r => r.score);
|
||||
|
||||
// Trace 1: Document results (always visible)
|
||||
const documentTrace = {
|
||||
x: coordinates.map(c => c[0]),
|
||||
y: coordinates.map(c => c[1]),
|
||||
z: coordinates.map(c => c[2]),
|
||||
mode: 'markers',
|
||||
type: 'scatter3d',
|
||||
name: 'Documents',
|
||||
visible: true,
|
||||
customdata: results.map((r, i) => ({
|
||||
title: r.title,
|
||||
raw_score: r.original_score,
|
||||
relative_score: r.score,
|
||||
x: coordinates[i][0],
|
||||
y: coordinates[i][1],
|
||||
z: coordinates[i][2]
|
||||
})),
|
||||
hovertemplate:
|
||||
'<b>%{customdata.title}</b><br>' +
|
||||
'Raw Score: %{customdata.raw_score:.3f} (%{customdata.relative_score:.0%} relative)<br>' +
|
||||
'(x=%{customdata.x}, y=%{customdata.y}, z=%{customdata.z})' +
|
||||
'<extra></extra>',
|
||||
marker: {
|
||||
size: results.map(r => 4 + (Math.pow(r.score, 2) * 10)),
|
||||
opacity: results.map(r => 0.3 + (r.score * 0.7)),
|
||||
color: scores,
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {
|
||||
title: 'Relative Score',
|
||||
x: 1.02,
|
||||
xanchor: 'left',
|
||||
thickness: 20,
|
||||
len: 0.8
|
||||
},
|
||||
cmin: 0,
|
||||
cmax: 1
|
||||
}
|
||||
};
|
||||
|
||||
// Trace 2: Query point (visibility controlled by toggle)
|
||||
const queryTrace = {
|
||||
x: [queryCoords[0]],
|
||||
y: [queryCoords[1]],
|
||||
z: [queryCoords[2]],
|
||||
mode: 'markers',
|
||||
type: 'scatter3d',
|
||||
name: 'Query',
|
||||
visible: this.showQueryPoint, // Initial visibility from state
|
||||
hovertemplate:
|
||||
'<b>Search Query</b><br>' +
|
||||
`(x=${queryCoords[0]}, y=${queryCoords[1]}, z=${queryCoords[2]})` +
|
||||
'<extra></extra>',
|
||||
marker: {
|
||||
size: 10,
|
||||
color: '#ef5350', // Subdued red (Material Design Red 400)
|
||||
line: {
|
||||
color: '#c62828', // Darker red border (Material Design Red 800)
|
||||
width: 1
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const layout = {
|
||||
title: `Vector Space (PCA 3D) - ${results.length} results`,
|
||||
width: width, // Explicit width from container
|
||||
height: height, // Explicit height from container
|
||||
scene: {
|
||||
xaxis: { title: 'PC1' },
|
||||
yaxis: { title: 'PC2' },
|
||||
zaxis: { title: 'PC3' },
|
||||
camera: {
|
||||
eye: { x: 1.5, y: 1.5, z: 1.5 }
|
||||
},
|
||||
// Full width for 3D scene
|
||||
domain: {
|
||||
x: [0, 1],
|
||||
y: [0, 1]
|
||||
}
|
||||
},
|
||||
hovermode: 'closest',
|
||||
autosize: true, // Enable auto-sizing for window resizes
|
||||
showlegend: false, // Hide legend
|
||||
margin: { l: 0, r: 100, t: 40, b: 0 } // Right margin for colorbar
|
||||
};
|
||||
|
||||
// Always render both traces - visibility is controlled by the visible property
|
||||
const traces = [documentTrace, queryTrace];
|
||||
|
||||
// Enable responsive resizing
|
||||
const config = {
|
||||
responsive: true,
|
||||
displayModeBar: true
|
||||
};
|
||||
|
||||
// Use newPlot() with explicit dimensions - renders at correct size immediately
|
||||
// Camera position will be preserved by subsequent Plotly.restyle() calls in updatePlot()
|
||||
Plotly.newPlot('viz-plot', traces, layout, config);
|
||||
},
|
||||
|
||||
getNextcloudUrl(result) {
|
||||
// Use global NEXTCLOUD_BASE_URL if set, otherwise construct from window location
|
||||
const baseUrl = window.NEXTCLOUD_BASE_URL || '';
|
||||
switch (result.doc_type) {
|
||||
case 'note':
|
||||
return `${baseUrl}/apps/notes/note/${result.id}`;
|
||||
case 'file':
|
||||
return `${baseUrl}/apps/files/?fileId=${result.id}`;
|
||||
case 'calendar':
|
||||
return `${baseUrl}/apps/calendar`;
|
||||
case 'contact':
|
||||
return `${baseUrl}/apps/contacts`;
|
||||
case 'deck_card':
|
||||
// URL pattern: /apps/deck/board/:boardId/card/:cardId
|
||||
if (result.metadata && result.metadata.board_id) {
|
||||
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
|
||||
}
|
||||
// Fallback if board_id not available
|
||||
return `${baseUrl}/apps/deck`;
|
||||
case 'news_item':
|
||||
return `${baseUrl}/apps/news/item/${result.id}`;
|
||||
default:
|
||||
return `${baseUrl}`;
|
||||
}
|
||||
},
|
||||
|
||||
hasChunkPosition(result) {
|
||||
return result.chunk_start_offset != null && result.chunk_end_offset != null;
|
||||
},
|
||||
|
||||
isChunkExpanded(resultKey) {
|
||||
return this.expandedChunks[resultKey] !== undefined;
|
||||
},
|
||||
|
||||
async toggleChunk(result) {
|
||||
const resultKey = `${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`;
|
||||
|
||||
if (this.isChunkExpanded(resultKey)) {
|
||||
delete this.expandedChunks[resultKey];
|
||||
return;
|
||||
}
|
||||
|
||||
this.chunkLoading[resultKey] = true;
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
doc_type: result.doc_type,
|
||||
doc_id: result.id,
|
||||
start: result.chunk_start_offset,
|
||||
end: result.chunk_end_offset,
|
||||
context: 500
|
||||
});
|
||||
|
||||
const response = await fetch(`/app/chunk-context?${params}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.expandedChunks[resultKey] = data;
|
||||
} else {
|
||||
alert('Failed to load chunk: ' + data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error loading chunk: ' + error.message);
|
||||
} finally {
|
||||
delete this.chunkLoading[resultKey];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -117,7 +117,14 @@ class RefreshTokenStorage:
|
||||
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize database schema"""
|
||||
"""
|
||||
Initialize database schema using Alembic migrations.
|
||||
|
||||
This method handles three scenarios:
|
||||
1. New database: Run migrations from scratch
|
||||
2. Pre-Alembic database: Stamp with initial revision (no changes)
|
||||
3. Alembic-managed database: Upgrade to latest version
|
||||
"""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
@@ -125,137 +132,59 @@ class RefreshTokenStorage:
|
||||
db_dir = Path(self.db_path).parent
|
||||
db_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Set restrictive permissions on database file
|
||||
# Set restrictive permissions on database file if it exists
|
||||
if Path(self.db_path).exists():
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
# Check database state and run appropriate migration strategy
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
encrypted_token BLOB NOT NULL,
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
||||
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
|
||||
provisioned_at INTEGER, -- When Flow 2 was completed
|
||||
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
|
||||
scopes TEXT, -- JSON array of granted scopes
|
||||
-- Browser session profile cache
|
||||
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
|
||||
profile_cached_at INTEGER -- When profile was last cached
|
||||
# Check if database is managed by Alembic
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
|
||||
)
|
||||
has_alembic = await cursor.fetchone() is not None
|
||||
|
||||
if not has_alembic:
|
||||
# Check if this is a pre-Alembic database with existing schema
|
||||
cursor = await db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
|
||||
)
|
||||
"""
|
||||
)
|
||||
has_schema = await cursor.fetchone() is not None
|
||||
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp INTEGER NOT NULL,
|
||||
event TEXT NOT NULL,
|
||||
user_id TEXT NOT NULL,
|
||||
resource_type TEXT,
|
||||
resource_id TEXT,
|
||||
auth_method TEXT,
|
||||
hostname TEXT
|
||||
if has_schema:
|
||||
logger.info(
|
||||
f"Detected pre-Alembic database at {self.db_path}, "
|
||||
"stamping with initial revision"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Initializing new database at {self.db_path} with migrations"
|
||||
)
|
||||
|
||||
# Run migrations in a worker thread using anyio.to_thread
|
||||
# This allows Alembic to run its own async operations in a separate context
|
||||
from anyio import to_thread
|
||||
|
||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||
|
||||
if not has_alembic:
|
||||
if has_schema:
|
||||
# Stamp existing database without running migrations
|
||||
await to_thread.run_sync(stamp_database, self.db_path, "001")
|
||||
logger.info(
|
||||
"Pre-Alembic database stamped successfully. "
|
||||
"Future schema changes will use migrations."
|
||||
)
|
||||
"""
|
||||
)
|
||||
else:
|
||||
# New database - run migrations
|
||||
await to_thread.run_sync(upgrade_database, self.db_path, "head")
|
||||
logger.info("Database initialized with migrations")
|
||||
else:
|
||||
# Alembic-managed database - upgrade to latest
|
||||
await to_thread.run_sync(upgrade_database, self.db_path, "head")
|
||||
logger.info("Database upgraded to latest version")
|
||||
|
||||
# Create index on audit logs for efficient queries
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
|
||||
"ON audit_logs(user_id, timestamp)"
|
||||
)
|
||||
|
||||
# OAuth client credentials storage
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_clients (
|
||||
id INTEGER PRIMARY KEY,
|
||||
client_id TEXT UNIQUE NOT NULL,
|
||||
encrypted_client_secret BLOB NOT NULL,
|
||||
client_id_issued_at INTEGER NOT NULL,
|
||||
client_secret_expires_at INTEGER NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
encrypted_registration_access_token BLOB,
|
||||
registration_client_uri TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# OAuth flow sessions (ADR-004 Progressive Consent)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oauth_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
client_redirect_uri TEXT NOT NULL,
|
||||
state TEXT,
|
||||
code_challenge TEXT,
|
||||
code_challenge_method TEXT,
|
||||
mcp_authorization_code TEXT UNIQUE,
|
||||
idp_access_token TEXT,
|
||||
idp_refresh_token TEXT,
|
||||
user_id TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
-- ADR-004 Progressive Consent fields
|
||||
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
|
||||
requested_scopes TEXT, -- JSON array of requested scopes
|
||||
granted_scopes TEXT, -- JSON array of granted scopes
|
||||
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create index for MCP authorization code lookups
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
|
||||
"ON oauth_sessions(mcp_authorization_code)"
|
||||
)
|
||||
|
||||
# Schema version tracking
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Registered webhooks tracking (both BasicAuth and OAuth modes)
|
||||
await db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS registered_webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
webhook_id INTEGER NOT NULL UNIQUE,
|
||||
preset_id TEXT NOT NULL,
|
||||
created_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Create indexes for efficient webhook queries
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
|
||||
"ON registered_webhooks(preset_id)"
|
||||
)
|
||||
await db.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
|
||||
"ON registered_webhooks(created_at)"
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Set restrictive permissions after creation
|
||||
# Set restrictive permissions after initialization
|
||||
os.chmod(self.db_path, 0o600)
|
||||
|
||||
self._initialized = True
|
||||
@@ -287,6 +216,8 @@ class RefreshTokenStorage:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Type narrowing: cipher is set after initialize()
|
||||
assert self.cipher is not None
|
||||
encrypted_token = self.cipher.encrypt(refresh_token.encode())
|
||||
now = int(time.time())
|
||||
scopes_json = json.dumps(scopes) if scopes else None
|
||||
@@ -432,6 +363,9 @@ class RefreshTokenStorage:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Type narrowing: cipher is set after initialize()
|
||||
assert self.cipher is not None
|
||||
|
||||
start_time = time.time()
|
||||
try:
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
@@ -516,6 +450,9 @@ class RefreshTokenStorage:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Type narrowing: cipher is set after initialize()
|
||||
assert self.cipher is not None
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
@@ -687,6 +624,9 @@ class RefreshTokenStorage:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Type narrowing: cipher is set after initialize()
|
||||
assert self.cipher is not None
|
||||
|
||||
# Encrypt sensitive data
|
||||
encrypted_secret = self.cipher.encrypt(client_secret.encode())
|
||||
encrypted_reg_token = (
|
||||
@@ -757,6 +697,9 @@ class RefreshTokenStorage:
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
# Type narrowing: cipher is set after initialize()
|
||||
assert self.cipher is not None
|
||||
|
||||
async with aiosqlite.connect(self.db_path) as db:
|
||||
async with db.execute(
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,524 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0082c9">
|
||||
<title>{% block title %}Nextcloud MCP Server{% endblock %}</title>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='32' viewBox='0 0 512 512'><rect width='512' height='512' rx='80' ry='80' fill='%230082C9'/><path d='M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z' fill='%23fff'/></svg>">
|
||||
|
||||
<!-- Open Sans font -->
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: local('Open Sans'), local('OpenSans');
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: local('Open Sans Semibold'), local('OpenSans-Semibold');
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<style>
|
||||
/* Nextcloud App Design System */
|
||||
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
/* Primary Colors */
|
||||
--color-primary: #00679e;
|
||||
--color-primary-element: #00679e;
|
||||
--color-primary-light: #e5eff5;
|
||||
--color-primary-element-light: #e5eff5;
|
||||
|
||||
/* Background Colors */
|
||||
--color-main-background: #ffffff;
|
||||
--color-background-dark: #ededed;
|
||||
--color-background-hover: #f5f5f5;
|
||||
|
||||
/* Text Colors */
|
||||
--color-main-text: #222222;
|
||||
--color-text-maxcontrast: #6b6b6b;
|
||||
--color-text-light: #767676;
|
||||
|
||||
/* Border Colors */
|
||||
--color-border: #ededed;
|
||||
--color-border-dark: #dbdbdb;
|
||||
|
||||
/* Borders & Radius */
|
||||
--border-radius: 3px;
|
||||
--border-radius-large: 10px;
|
||||
--border-radius-pill: 100px;
|
||||
|
||||
/* Spacing */
|
||||
--default-grid-baseline: 4px;
|
||||
--default-clickable-area: 44px;
|
||||
}
|
||||
|
||||
/* SVG Icon Styles */
|
||||
.nav-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: inline-block;
|
||||
fill: var(--color-main-text);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-navigation-entry.active .nav-icon {
|
||||
fill: var(--color-primary-element);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* General */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
color: var(--color-main-text);
|
||||
background: var(--color-main-background);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-weight: 300;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 32px;
|
||||
margin: 0 0 20px 0;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
margin: 20px 0 12px 0;
|
||||
color: var(--color-main-text);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 16px 0 8px 0;
|
||||
color: var(--color-main-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* App Header (simplified, no full menu) */
|
||||
.app-header {
|
||||
height: 50px;
|
||||
background: var(--color-primary-element);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-header__brand {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.app-header__brand:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.app-header__logo {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* App Layout */
|
||||
.app-content-wrapper {
|
||||
display: flex;
|
||||
height: calc(100vh - 50px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Side Navigation */
|
||||
#app-navigation {
|
||||
width: 250px;
|
||||
background: var(--color-main-background);
|
||||
border-right: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
transition: margin-left 0.3s ease;
|
||||
}
|
||||
|
||||
#app-navigation.app-navigation--closed {
|
||||
margin-left: -250px;
|
||||
}
|
||||
|
||||
.app-navigation__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.app-navigation-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.app-navigation-entry {
|
||||
position: relative;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.app-navigation-entry__wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-navigation-entry-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
min-height: var(--default-clickable-area);
|
||||
border-radius: var(--border-radius);
|
||||
transition: background-color 100ms ease-in-out;
|
||||
text-decoration: none;
|
||||
color: var(--color-main-text);
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.app-navigation-entry-link:hover {
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
|
||||
.app-navigation-entry.active .app-navigation-entry-link {
|
||||
background-color: var(--color-primary-element-light);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.app-navigation-entry-icon {
|
||||
width: var(--default-clickable-area);
|
||||
height: var(--default-clickable-area);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.app-navigation-entry__name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-navigation-entry__counter {
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-pill);
|
||||
background-color: var(--color-background-dark);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
min-width: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.app-navigation__settings {
|
||||
list-style: none;
|
||||
padding: 8px 0 0 0;
|
||||
margin: 8px 0 0 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.app-navigation-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: fixed;
|
||||
top: 60px;
|
||||
left: 10px;
|
||||
z-index: 110;
|
||||
background: var(--color-main-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 5px rgba(0,0,0,0.1);
|
||||
transition: left 0.3s ease;
|
||||
}
|
||||
|
||||
.app-navigation-toggle:hover {
|
||||
background: var(--color-background-hover);
|
||||
}
|
||||
|
||||
#app-navigation:not(.app-navigation--closed) ~ * .app-navigation-toggle {
|
||||
left: 260px;
|
||||
}
|
||||
|
||||
/* Main Content Area */
|
||||
#app-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background: var(--color-main-background);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.content-section {
|
||||
background: var(--color-main-background);
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.content-section h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.content-section h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
margin: 24px 0 12px 0;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.content-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
#app-navigation {
|
||||
position: fixed;
|
||||
height: calc(100vh - 50px);
|
||||
z-index: 105;
|
||||
box-shadow: 2px 0 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer.page-footer {
|
||||
background-color: #0F0833;
|
||||
color: #ffffff;
|
||||
padding: 40px 0;
|
||||
margin-top: 60px;
|
||||
}
|
||||
|
||||
footer.page-footer .bootstrap-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
footer.page-footer h1 {
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
footer.page-footer ul {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
footer.page-footer li {
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
footer.page-footer li a {
|
||||
color: #ffffff;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
footer.page-footer li a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
footer.page-footer p {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
footer.page-footer p.copyright {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
border-radius: 50px;
|
||||
padding: 10px 20px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #0082C9;
|
||||
border: 1px solid #0062C9;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #006ba3;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
width: 180px;
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--color-background-dark);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius);
|
||||
font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace;
|
||||
font-size: 90%;
|
||||
color: var(--color-main-text);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-oauth {
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-basic {
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.warning {
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.info-message {
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #c62828;
|
||||
}
|
||||
|
||||
.success {
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.success h1 {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
{% block extra_styles %}{% endblock %}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- App Header -->
|
||||
<header class="app-header">
|
||||
<a href="/app" class="app-header__brand">
|
||||
<svg class="app-header__logo" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path d="M255.9 21.04c-11.8 0-22.2 4.08-28.6 10.01-5.6 4.98-8.6 11.41-8.6 18.11 0 5.55 2.2 11.01 5.9 15.48-16.4 4.97-30.1 13.64-39 24.53 22.1-7.67 45.7-11.86 70.3-11.86 24.6 0 48.3 4.19 70.3 11.86-8.9-10.89-22.6-19.56-39-24.53 3.9-4.47 5.9-9.93 5.9-15.48 0-6.7-3-13.13-8.5-18.11-6.4-5.93-16.9-10.01-28.7-10.01zm0 20.34c5.3 0 10.1 1.27 13.6 3.52 1.7 1.16 3.4 2.43 3.4 4.27 0 1.76-1.7 3.03-3.4 4.19-3.5 2.33-8.3 3.61-13.6 3.61-5.3 0-10.1-1.28-13.6-3.61-1.6-1.16-3.3-2.43-3.3-4.19 0-1.84 1.7-3.11 3.3-4.27 3.5-2.25 8.3-3.52 13.6-3.52zm.1 48.1c-110.8 0-200.72 90.02-200.72 200.82S145.2 491 256 491s200.7-89.9 200.7-200.7c0-110.8-89.9-200.82-200.7-200.82zm0 32.62c92.9 0 168.2 75.3 168.2 168.2 0 92.8-75.3 168.2-168.2 168.2-92.9 0-168.26-75.4-168.26-168.2 0-92.9 75.36-168.2 168.26-168.2zm-8.2 6.3c-9.6.5-19 1.9-28.3 4.1l2.3 7.8c8.4-2 17.1-3.3 26-3.8v-8.1zm16.2 0v8.1c9 .5 17.7 1.8 26 3.8l2.2-7.8c-9.1-2.2-18.6-3.6-28.2-4.1zm-60 8.5c-9 3.2-17.6 7-25.8 11.6l4.1 7.1c7.7-4.3 15.6-7.9 23.9-10.8l-2.2-7.9zm103.7 0-2 7.9c8.4 2.9 16.2 6.5 23.8 10.8l4.2-7.1c-8.2-4.6-16.9-8.4-26-11.6zm-143.3 20.3c-7.5 5.4-14.6 11.4-21.1 17.9l5.8 5.8c5.9-6.1 12.5-11.7 19.5-16.6l-4.2-7.1zm182.9 0-4 7.1c6.9 4.9 13.5 10.5 19.5 16.6l5.7-5.8c-6.5-6.5-13.7-12.5-21.2-17.9zm-91.4 11.5c-37 0-67.4 28.6-70.3 64.9l15.9 4.7c.7-29.6 24.7-53.4 54.4-53.4 30.1 0 54.4 24.4 54.4 54.3 0 15-6.2 28.7-16 38.5l.1.1c1.7 2.7 3 5.6 4.1 8.6.9 3 1.7 5.7 2.3 8.6v.4c33.8-16.7 57.2-51.5 57.2-91.7 0-3.8-.2-7.3-.6-10.9-3.2-3.3-6.3-6.4-9.8-9.5 1.5 6.5 2.3 13.4 2.3 20.4 0 28.7-13 54.7-33.5 71.8 6.3-10.6 10.1-23 10.1-36.3 0-38.9-31.7-70.5-70.6-70.5zm-91.8 14.6c-3.3 3.1-6.5 6.2-9.7 9.5-.3 3.6-.5 7.1-.5 10.9 0 7.3.7 14.2 2.1 20.9l9.1 2.7c-2.1-7.5-3.1-15.4-3.1-23.6 0-7 .7-13.9 2.1-20.4zm-31.6 4c-5.8 7.1-10.9 14.6-15.4 22.6l7.1 4c4.1-7.4 8.8-14.3 14-20.8l-5.7-5.8zm246.8 0-5.7 5.8c5.3 6.5 10 13.4 13.9 20.8l7.1-4c-4.4-8-9.5-15.5-15.3-22.6zm-269.2 37.1c-2.5 5.7-4.6 11.4-6.4 17.6l.1-.3c3.4-5 7.9-9.3 12.9-12.5l.3-.6-6.9-4.2zm291.8 0-7.2 4.2c3.2 7.3 5.7 15.1 7.6 23.1l7.9-2.1c-2.1-8.8-4.9-17.3-8.3-25.2zm-261.2 11.5c-13.4.1-25.7 9-29.7 22.5l114.8 34.2c-4.9 16.7 4.6 34.2 21.2 39.2L361.7 366c16.6 5 34.1-4.4 39.1-21l-114.6-34.4c4.9-16.5-4.7-34.1-21.3-39.1 0 0-72.4-21.5-114.8-34.3-3.1-.9-6.3-1.4-9.4-1.3zm-42.09 29.7c-.9 6.9-1.4 14-1.4 21.3 0 1.3.1 2.9.1 4.2h8.09v-4.2c0-6.5.4-12.9 1.2-19.2l-7.99-2.1zm314.59 0-7.9 2.1c.7 6.3 1.3 12.7 1.3 19.2 0 1.3 0 2.9-.2 4.2h8.2v-4.2c0-7.3-.5-14.4-1.4-21.3zm-157.3 24.7c6.3 0 11.5 5 11.5 11.3 0 6.4-5.2 11.6-11.5 11.6s-11.5-5.2-11.5-11.6c0-6.3 5.2-11.3 11.5-11.3zM98.51 307.4c1 8.2 2.89 16.4 5.09 24.3l7.9-2.1c-2.1-7.2-3.8-14.6-4.8-22.2h-8.19zm306.69 0c-1.1 7.6-2.7 15-4.8 22.2l7.8 2.1c2.2-7.9 4.1-16.1 5.2-24.3h-8.2zm-191.3 10.9c-19 13.3-31.4 35.3-31.4 60.1 0 10.4 2.3 20.4 6.2 29.7 8.8 4.9 17.9 8.8 27.6 11.7-10.8-10.7-17.5-25.2-17.5-41.4 0-19 9.3-36 23.7-46.3-3.8-4.1-6.7-8.7-8.6-13.8zM116.8 345l-7.9 2c3.1 7.6 6.8 14.7 11 21.6l6.9-4.2c-3.8-6.2-7-12.8-10-19.4zm194.8 20.5c.9 4.1 1.4 8.5 1.4 12.9 0 16.2-6.7 30.7-17.4 41.4 9.6-2.9 18.8-6.8 27.5-11.7 4-9.3 6.2-19.3 6.2-29.7 0-2.7-.2-5.2-.4-7.7l-17.3-5.2zM136 377.9l-7.1 4.1c4.7 6.2 9.7 12.1 15.3 17.3l5.7-5.5c-5.1-5-9.7-10.3-13.9-15.9zm243.9 2.3-.2.1c-2.1.3-4 .6-6.2.7h-.1c-3.6 4.5-7.3 8.8-11.5 12.8l5.8 5.5c5.5-5.2 10.5-11.1 15.2-17.3l-3-1.8zm-217.8 24-5.9 5.9c6 4.8 12.2 9.7 18.8 13.6l3.8-7.8c-5.7-2.9-11.4-6.8-16.7-11.7zm187.7 0c-5.4 4.9-11.1 8.8-16.8 11.7l3.9 7.8c6.5-3.9 12.8-8.8 18.7-13.6l-5.8-5.9zm-156.4 19.5-4.1 6.8c6.6 4 13.7 5.8 20.7 8.8l2.2-7.9c-6.5-1.9-12.7-4.8-18.8-7.7zm125.2 0c-6.2 2.9-12.5 5.8-19.1 7.7l2.3 7.9c7.2-3 14-4.8 20.7-8.8l-3.9-6.8zm-90.7 11.7-2 7.8c7.1 1 14.5 1.9 21.9 1.9v-7.7c-6.8 0-13.5-1.1-19.9-2zm55.9 0c-6.3.9-13 2-19.8 2v7.7c7.5 0 14.8-.9 22.1-1.9l-2.3-7.8z" fill="#fff"/>
|
||||
</svg>
|
||||
<span>Nextcloud MCP Server</span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<!-- App Content Wrapper (Sidebar + Main Content) -->
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ error_title|default('Error') }} - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{{ error_title|default('Error') }}</h1>
|
||||
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {{ error_message }}
|
||||
</div>
|
||||
|
||||
{% if login_url %}
|
||||
<p><a href="{{ login_url }}" class="btn btn-primary">Login again</a></p>
|
||||
{% endif %}
|
||||
|
||||
{% if back_url %}
|
||||
<p><a href="{{ back_url }}" class="btn">Go Back</a></p>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ success_title|default('Success') }} - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
{% if redirect_url and redirect_delay %}
|
||||
<meta http-equiv="refresh" content="{{ redirect_delay }};url={{ redirect_url }}">
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="success">
|
||||
<h1>{{ success_title|default('✓ Success') }}</h1>
|
||||
{% for message in success_messages %}
|
||||
<p>{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% if redirect_url %}
|
||||
<p>Redirecting...</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,650 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- htmx for dynamic loading -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for state management -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Plotly.js for vector visualization -->
|
||||
<script src="https://cdn.plot.ly/plotly-3.3.0.min.js"></script>
|
||||
|
||||
<!-- Vector Viz static assets -->
|
||||
<link rel="stylesheet" href="/app/static/vector-viz.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
/* Smooth htmx transitions */
|
||||
.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}
|
||||
|
||||
.htmx-settling {
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}
|
||||
|
||||
/* Logout button styling */
|
||||
.logout-section {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* Welcome tab specific styles */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%);
|
||||
color: white;
|
||||
padding: 60px 24px;
|
||||
margin: -24px -24px 40px -24px;
|
||||
border-radius: 0 0 var(--border-radius-large) var(--border-radius-large);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-section p {
|
||||
font-size: 18px;
|
||||
opacity: 0.95;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--color-primary-element);
|
||||
font-size: 20px;
|
||||
margin: 12px 0 8px 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-primary-element-light);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 32px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
color: var(--color-main-text);
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.info-section code {
|
||||
background: var(--color-main-background);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-status svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--color-primary-element);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auth-status-text strong {
|
||||
display: block;
|
||||
color: var(--color-main-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-status-text span {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="app-content-wrapper" x-data="{ activeSection: 'welcome', navOpen: true }">
|
||||
<!-- Side Navigation -->
|
||||
<nav id="app-navigation" :class="{ 'app-navigation--closed': !navOpen }">
|
||||
<div class="app-navigation__content">
|
||||
<!-- Navigation List -->
|
||||
<ul class="app-navigation-list">
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'welcome' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'welcome'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M10,20V14H14V20H19V12H22L12,3L2,12H5V20H10Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Welcome</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'user-info' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'user-info'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">User Info</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
{% if show_vector_sync_tab %}
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-sync' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'vector-sync'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Vector Sync</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'vector-viz' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'vector-viz'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Vector Viz</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% if show_webhooks_tab %}
|
||||
<li class="app-navigation-entry" :class="{ 'active': activeSection === 'webhooks' }">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="#"
|
||||
@click.prevent="activeSection = 'webhooks'"
|
||||
class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M10.59,13.41C11,13.8 11,14.44 10.59,14.83C10.2,15.22 9.56,15.22 9.17,14.83C7.22,12.88 7.22,9.71 9.17,7.76V7.76L12.71,4.22C14.66,2.27 17.83,2.27 19.78,4.22C21.73,6.17 21.73,9.34 19.78,11.29L18.29,12.78C18.3,11.96 18.17,11.14 17.89,10.36L18.36,9.88C19.54,8.71 19.54,6.81 18.36,5.64C17.19,4.46 15.29,4.46 14.12,5.64L10.59,9.17C9.41,10.34 9.41,12.24 10.59,13.41M13.41,9.17C13.8,8.78 14.44,8.78 14.83,9.17C16.78,11.12 16.78,14.29 14.83,16.24V16.24L11.29,19.78C9.34,21.73 6.17,21.73 4.22,19.78C2.27,17.83 2.27,14.66 4.22,12.71L5.71,11.22C5.7,12.04 5.83,12.86 6.11,13.65L5.64,14.12C4.46,15.29 4.46,17.19 5.64,18.36C6.81,19.54 8.71,19.54 9.88,18.36L13.41,14.83C14.59,13.66 14.59,11.76 13.41,10.59C13,10.2 13,9.56 13.41,9.17Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Webhooks</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<!-- Settings/Logout at bottom -->
|
||||
{% if logout_url %}
|
||||
<ul class="app-navigation__settings">
|
||||
<li class="app-navigation-entry">
|
||||
<div class="app-navigation-entry__wrapper">
|
||||
<a href="{{ logout_url }}" class="app-navigation-entry-link">
|
||||
<span class="app-navigation-entry-icon">
|
||||
<svg class="nav-icon" viewBox="0 0 24 24">
|
||||
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="app-navigation-entry__name">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Toggle Button (mobile) -->
|
||||
<button @click="navOpen = !navOpen"
|
||||
class="app-navigation-toggle"
|
||||
:aria-expanded="navOpen.toString()">
|
||||
☰
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main id="app-content">
|
||||
<div class="page-content">
|
||||
<!-- Welcome Section -->
|
||||
<div x-show="activeSection === 'welcome'">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>Welcome to Nextcloud MCP Server</h1>
|
||||
<p>
|
||||
Interactive user interface for semantic search and document retrieval.
|
||||
Test queries, visualize results, and explore your Nextcloud content using RAG workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Status -->
|
||||
<div class="auth-status">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
<div class="auth-status-text">
|
||||
<strong>Authenticated as: {{ username }}</strong>
|
||||
<span>Authentication mode: <code>{{ auth_mode }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if vector_sync_enabled %}
|
||||
<!-- Vector Sync Enabled Content -->
|
||||
<div class="info-section">
|
||||
<h2>About Semantic Search</h2>
|
||||
<p>
|
||||
This interface provides access to <strong>semantic search</strong> capabilities powered by vector embeddings.
|
||||
Unlike traditional keyword search, semantic search understands the <em>meaning</em> of your queries and finds
|
||||
conceptually similar content across your Nextcloud apps.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database</li>
|
||||
<li>Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning</li>
|
||||
<li>Queries are also converted to embeddings and matched against document vectors using similarity search</li>
|
||||
<li>Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>RAG Workflow Integration</h2>
|
||||
<p>
|
||||
This UI allows you to <strong>test the same queries that Large Language Models (LLMs) would use</strong> in a
|
||||
Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Step 1:</strong> The assistant converts your question into a search query</li>
|
||||
<li><strong>Step 2:</strong> The MCP server retrieves relevant document chunks using semantic search</li>
|
||||
<li><strong>Step 3:</strong> Retrieved context is passed to the LLM to generate an informed answer</li>
|
||||
</ul>
|
||||
|
||||
<!-- RAG Workflow Diagram -->
|
||||
<div style="background: var(--color-main-background); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 24px; margin: 24px 0; overflow-x: auto;">
|
||||
<div style="text-align: center; font-weight: 600; margin-bottom: 20px; color: var(--color-primary-element); font-size: 16px;">
|
||||
MCP Sampling RAG Workflow
|
||||
</div>
|
||||
|
||||
<!-- Four-component bidirectional flow -->
|
||||
<div style="max-width: 1000px; margin: 0 auto;">
|
||||
<div style="display: grid; grid-template-columns: 0.7fr auto 1fr auto 1fr auto 0.9fr; gap: 10px; align-items: center;">
|
||||
<!-- User -->
|
||||
<div style="background: var(--color-background-hover); border: 2px solid var(--color-border); border-radius: var(--border-radius-large); padding: 14px; text-align: center;">
|
||||
<div style="font-size: 26px; margin-bottom: 5px;">👤</div>
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 12px;">User</div>
|
||||
<div style="font-size: 9px; color: var(--color-text-maxcontrast); font-style: italic; margin-top: 5px; line-height: 1.2;">
|
||||
"What are health<br>benefits of coffee?"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow User <-> Client -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-text-maxcontrast);">↔</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Client + LLM (combined) -->
|
||||
<div style="background: var(--color-primary-element-light); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 12px; text-align: center;">
|
||||
<div style="font-weight: 600; color: var(--color-primary-element); font-size: 13px; margin-bottom: 8px;">MCP Client + LLM</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 8px; margin-bottom: 6px;">
|
||||
<div style="font-size: 9px; color: var(--color-text-maxcontrast);">(Claude Code)</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 8px; border: 2px solid var(--color-primary-element);">
|
||||
<div style="font-size: 16px; margin-bottom: 2px;">🧠</div>
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 10px;">Client's LLM</div>
|
||||
<div style="font-size: 8px; color: var(--color-text-maxcontrast);">(Claude)</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 8px; font-size: 8px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
<strong>Enables RAG:</strong><br>
|
||||
Receives context,<br>
|
||||
generates answer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow Client <-> Server -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-primary-element);">↔</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); margin-top: 2px; font-weight: 600; line-height: 1.1;">
|
||||
Query +<br>
|
||||
Sampling
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP Server -->
|
||||
<div style="background: var(--color-primary-element-light); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 12px; text-align: center;">
|
||||
<div style="font-weight: 600; color: var(--color-primary-element); font-size: 13px; margin-bottom: 8px;">MCP Server</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">1. Semantic Search</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Vector embeddings<br>
|
||||
BM25 Hybrid + RRF
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">2. Retrieve Context</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Top relevant docs<br>
|
||||
with scores
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px; margin-bottom: 5px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">3. Format Response</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Document chunks<br>
|
||||
with citations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background: var(--color-main-background); border-radius: var(--border-radius); padding: 7px;">
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 9px; margin-bottom: 2px;">4. Send to LLM</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Via MCP sampling<br>
|
||||
for answer generation
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow Server <-> Nextcloud -->
|
||||
<div style="text-align: center;">
|
||||
<div style="font-size: 20px; color: var(--color-primary-element);">↔</div>
|
||||
<div style="font-size: 7px; color: var(--color-text-maxcontrast); margin-top: 2px; font-weight: 600; line-height: 1.1;">
|
||||
Retrieve
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nextcloud -->
|
||||
<div style="background: var(--color-background-hover); border: 2px solid var(--color-border); border-radius: var(--border-radius-large); padding: 12px; text-align: center; position: relative;">
|
||||
<img src="/app/static/nextcloud-logo.png" alt="Nextcloud" style="width: 40px; height: 40px; margin-bottom: 6px;" />
|
||||
<div style="font-weight: 600; color: var(--color-main-text); font-size: 12px; margin-bottom: 4px;">Nextcloud</div>
|
||||
<div style="font-size: 8px; color: var(--color-text-maxcontrast); line-height: 1.2;">
|
||||
Notes, Calendar,<br>
|
||||
Files, Contacts,<br>
|
||||
Deck
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Explanation below diagram -->
|
||||
<div style="margin-top: 24px; padding: 16px; background: var(--color-background-hover); border-radius: var(--border-radius); border-left: 4px solid var(--color-primary-element);">
|
||||
<div style="font-size: 12px; color: var(--color-main-text); line-height: 1.6;">
|
||||
<strong>How RAG works via MCP Sampling:</strong>
|
||||
</div>
|
||||
<ol style="margin: 8px 0 0 0; padding-left: 20px; font-size: 11px; color: var(--color-text-maxcontrast); line-height: 1.6;">
|
||||
<li>User asks question through MCP Client</li>
|
||||
<li>Client sends query to MCP Server</li>
|
||||
<li>Server retrieves relevant document context from Nextcloud</li>
|
||||
<li><strong>Server sends context back to Client's LLM</strong> (MCP Sampling)</li>
|
||||
<li>Client's LLM generates answer with citations using retrieved context</li>
|
||||
<li>Answer returned to user</li>
|
||||
</ol>
|
||||
<div style="margin-top: 8px; font-size: 10px; color: var(--color-text-maxcontrast); font-style: italic;">
|
||||
The server has no LLM - it only retrieves context. The client's existing LLM is reused for answer generation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>Key Point:</strong> The MCP server retrieves context but doesn't generate answers itself.
|
||||
Through <strong>MCP sampling</strong>, it requests the client's LLM to generate responses, giving users
|
||||
full control over which model is used and ensuring all processing happens client-side.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By using this interface, you can preview search results, understand relevance scores, and verify
|
||||
that the system retrieves the right information before it reaches the LLM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="#" @click.prevent="activeSection = 'user-info'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="activeSection = 'vector-sync'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Sync Status</h3>
|
||||
<p>
|
||||
Monitor real-time indexing progress with metrics for indexed documents, pending queue,
|
||||
and synchronization status.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="activeSection = 'vector-viz'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Visualization</h3>
|
||||
<p>
|
||||
Interactive search interface with 2D PCA visualization. Compare algorithms,
|
||||
view relevance scores, and explore matched document chunks.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Vector Sync Disabled Content -->
|
||||
<div class="warning">
|
||||
<h3 style="margin-top: 0;">Vector Sync is Disabled</h3>
|
||||
<p>
|
||||
Semantic search and vector visualization features are currently disabled.
|
||||
To enable these features, set <code>VECTOR_SYNC_ENABLED=true</code> in your environment configuration.
|
||||
</p>
|
||||
<p style="margin-bottom: 0;">
|
||||
<strong>Learn more:</strong>
|
||||
<a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank" style="color: inherit; text-decoration: underline;">
|
||||
Configuration Guide
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Limited Feature Card -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="#" @click.prevent="activeSection = 'user-info'" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Documentation Section -->
|
||||
<div class="info-section" style="margin-top: 40px;">
|
||||
<h2>Documentation</h2>
|
||||
<p>
|
||||
For detailed information about configuration, authentication modes, and advanced features,
|
||||
please refer to the project documentation:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md" target="_blank">Installation Guide</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">Configuration Options</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/authentication.md" target="_blank">Authentication Modes</a></li>
|
||||
{% if vector_sync_enabled %}
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/user-guide/vector-sync-ui.md" target="_blank">Vector Sync UI Guide</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Info Section -->
|
||||
<div x-show="activeSection === 'user-info'">
|
||||
<div class="content-section">
|
||||
<h1>User Information</h1>
|
||||
{{ user_info_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if show_vector_sync_tab %}
|
||||
<!-- Vector Sync Section -->
|
||||
<div x-show="activeSection === 'vector-sync'">
|
||||
<div class="content-section">
|
||||
<h1>Vector Sync Status</h1>
|
||||
{{ vector_sync_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vector Viz Section -->
|
||||
<div x-show="activeSection === 'vector-viz'">
|
||||
<div class="content-section">
|
||||
<h1>Vector Visualization</h1>
|
||||
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
|
||||
<p style="color: #999;">Loading vector visualization...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if show_webhooks_tab %}
|
||||
<!-- Webhooks Section -->
|
||||
<div x-show="activeSection === 'webhooks'">
|
||||
<div class="content-section">
|
||||
<h1>Webhook Management</h1>
|
||||
{{ webhooks_tab_html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Set global Nextcloud base URL for use in external JS
|
||||
window.NEXTCLOUD_BASE_URL = '{{ nextcloud_host_for_links }}';
|
||||
</script>
|
||||
<script src="/app/static/vector-viz.js"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,184 @@
|
||||
<div x-data="vizApp()">
|
||||
<div class="viz-layout">
|
||||
<!-- Top: Search Controls -->
|
||||
<div class="viz-card viz-controls-card">
|
||||
<form @submit.prevent="executeSearch">
|
||||
<div class="viz-controls-grid">
|
||||
<div class="viz-control-group">
|
||||
<label>Search Query</label>
|
||||
<input type="text" x-model="query" placeholder="Enter search query..." required />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Algorithm</label>
|
||||
<select x-model="algorithm">
|
||||
<option value="semantic">Semantic (Dense)</option>
|
||||
<option value="bm25_hybrid" selected>BM25 Hybrid</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Fusion</label>
|
||||
<select x-model="fusion" :disabled="algorithm !== 'bm25_hybrid'" :style="algorithm !== 'bm25_hybrid' ? 'opacity: 0.5; cursor: not-allowed;' : ''">
|
||||
<option value="rrf" selected>RRF</option>
|
||||
<option value="dbsf">DBSF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label> </label>
|
||||
<button type="submit" class="viz-btn">Search</button>
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label> </label>
|
||||
<button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced">
|
||||
<span x-text="showAdvanced ? 'Hide' : 'Advanced'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options (Collapsible) -->
|
||||
<div x-show="showAdvanced" style="margin-top: 16px;">
|
||||
<div class="viz-controls-grid" style="grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));">
|
||||
<div class="viz-control-group">
|
||||
<label>Document Types</label>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 13px;">
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="" style="margin-right: 4px;">
|
||||
<span>All</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="note" style="margin-right: 4px;">
|
||||
<span>Notes</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="file" style="margin-right: 4px;">
|
||||
<span>Files</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="calendar" style="margin-right: 4px;">
|
||||
<span>Calendar</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="contact" style="margin-right: 4px;">
|
||||
<span>Contacts</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
|
||||
<span>Deck Cards</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
|
||||
<span>News</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Score Threshold</label>
|
||||
<input type="number" x-model.number="scoreThreshold" min="0" max="1" step="any" />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Result Limit</label>
|
||||
<input type="number" x-model.number="limit" min="1" max="1000" />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Display Options</label>
|
||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal; margin-top: 4px;">
|
||||
<input type="checkbox" x-model="showQueryPoint" @change="updatePlot()" style="margin-right: 6px;">
|
||||
<span>Show Query Point</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Plot -->
|
||||
<div class="viz-card viz-card-plot">
|
||||
<div id="viz-plot-container">
|
||||
<div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms>
|
||||
Executing search and computing PCA projection...
|
||||
</div>
|
||||
<div id="viz-plot" x-show="!loading" x-transition.opacity.duration.200ms></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="viz-card" style="flex: 0 0 auto;">
|
||||
<h3 style="margin-top: 0;">Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3>
|
||||
|
||||
<div x-show="loading" class="viz-loading" x-transition.opacity.duration.200ms>
|
||||
Loading results...
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && results.length === 0" class="viz-no-results" x-transition.opacity.duration.200ms>
|
||||
No results found. Try a different query or adjust your search parameters.
|
||||
</div>
|
||||
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<div x-transition.opacity.duration.200ms>
|
||||
<template x-for="result in results" :key="`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`">
|
||||
<div style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<a :href="getNextcloudUrl(result)" target="_blank" style="font-weight: 500; color: #0066cc; text-decoration: none;">
|
||||
<span x-text="result.title"></span>
|
||||
</a>
|
||||
<div style="font-size: 14px; color: #666; margin-top: 4px;"
|
||||
x-text="result.excerpt.length > 200 ? result.excerpt.substring(0, 200) + '...' : result.excerpt"></div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Raw Score: <span x-text="result.original_score.toFixed(3)"></span>
|
||||
(<span x-text="(result.score * 100).toFixed(0)"></span>% relative) |
|
||||
Type: <span x-text="result.doc_type"></span>
|
||||
</div>
|
||||
|
||||
<!-- Show Chunk button (only if chunk position is available) -->
|
||||
<template x-if="hasChunkPosition(result)">
|
||||
<button
|
||||
class="chunk-toggle-btn"
|
||||
@click="toggleChunk(result)"
|
||||
x-text="isChunkExpanded(`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`) ? 'Hide Chunk' : 'Show Chunk'"
|
||||
></button>
|
||||
</template>
|
||||
|
||||
<!-- Chunk context (expanded inline) -->
|
||||
<template x-if="isChunkExpanded(`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`)">
|
||||
<div class="chunk-context" x-transition.opacity.duration.200ms>
|
||||
<template x-if="chunkLoading[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]">
|
||||
<div style="color: #666; font-style: italic;">Loading chunk...</div>
|
||||
</template>
|
||||
<template x-if="!chunkLoading[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]">
|
||||
<div>
|
||||
<!-- Highlighted page image for PDFs -->
|
||||
<template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.highlighted_page_image">
|
||||
<div class="chunk-image-container">
|
||||
<div class="chunk-image-header">
|
||||
<span>Page <span x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.page_number"></span></span>
|
||||
</div>
|
||||
<img
|
||||
:src="'data:image/png;base64,' + expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.highlighted_page_image"
|
||||
:alt="'Page ' + expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.page_number"
|
||||
class="chunk-highlighted-image"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Text context -->
|
||||
<template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.has_more_before">
|
||||
<span class="chunk-ellipsis">...</span>
|
||||
</template>
|
||||
<span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.before_context"></span><span class="chunk-matched" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.chunk_text"></span><span class="chunk-text" x-text="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.after_context"></span><template x-if="expandedChunks[`${result.doc_type}_${result.id}_${result.chunk_start_offset || 0}`]?.has_more_after">
|
||||
<span class="chunk-ellipsis">...</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div><!-- Search Results -->
|
||||
</div><!-- .viz-layout -->
|
||||
</div><!-- x-data="vizApp()" -->
|
||||
@@ -0,0 +1,392 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Welcome - Nextcloud MCP Server{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- Alpine.js for interactive elements -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_styles %}
|
||||
/* Welcome page specific styles */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-element) 0%, #0082c9 100%);
|
||||
color: white;
|
||||
padding: 60px 24px;
|
||||
margin: -24px -24px 40px -24px;
|
||||
border-radius: 0 0 var(--border-radius-large) var(--border-radius-large);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
color: white;
|
||||
font-size: 36px;
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero-section p {
|
||||
font-size: 18px;
|
||||
opacity: 0.95;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: var(--color-main-background);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 24px;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
border-color: var(--color-primary-element);
|
||||
box-shadow: 0 4px 12px rgba(0, 103, 158, 0.15);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.feature-card h3 {
|
||||
color: var(--color-primary-element);
|
||||
font-size: 20px;
|
||||
margin: 12px 0 8px 0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 8px 0 0 0;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: var(--color-primary-element-light);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
fill: var(--color-primary-element);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: var(--color-background-hover);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 32px;
|
||||
margin: 32px 0;
|
||||
}
|
||||
|
||||
.info-section h2 {
|
||||
color: var(--color-main-text);
|
||||
font-size: 24px;
|
||||
margin: 0 0 16px 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.info-section ul {
|
||||
margin: 12px 0;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.info-section li {
|
||||
color: var(--color-text-maxcontrast);
|
||||
line-height: 1.7;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.info-section code {
|
||||
background: var(--color-main-background);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auth-status {
|
||||
background: var(--color-primary-element-light);
|
||||
border-left: 4px solid var(--color-primary-element);
|
||||
padding: 16px 20px;
|
||||
margin: 24px 0;
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-status svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
fill: var(--color-primary-element);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auth-status-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auth-status-text strong {
|
||||
display: block;
|
||||
color: var(--color-main-text);
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.auth-status-text span {
|
||||
color: var(--color-text-maxcontrast);
|
||||
font-size: 13px;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="app-content-wrapper">
|
||||
<!-- Main Content Area -->
|
||||
<main id="app-content">
|
||||
<div class="page-content">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section">
|
||||
<h1>Welcome to Nextcloud MCP Server</h1>
|
||||
<p>
|
||||
Interactive user interface for semantic search and document retrieval.
|
||||
Test queries, visualize results, and explore your Nextcloud content using RAG workflows.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authentication Status -->
|
||||
<div class="auth-status">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
<div class="auth-status-text">
|
||||
<strong>Authenticated as: {{ username }}</strong>
|
||||
<span>Authentication mode: <code>{{ auth_mode }}</code></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if vector_sync_enabled %}
|
||||
<!-- Vector Sync Enabled Content -->
|
||||
<div class="info-section">
|
||||
<h2>About Semantic Search</h2>
|
||||
<p>
|
||||
This interface provides access to <strong>semantic search</strong> capabilities powered by vector embeddings.
|
||||
Unlike traditional keyword search, semantic search understands the <em>meaning</em> of your queries and finds
|
||||
conceptually similar content across your Nextcloud apps.
|
||||
</p>
|
||||
<p>
|
||||
<strong>How it works:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Documents from Notes, Calendar, Files, Contacts, and Deck are indexed into a vector database</li>
|
||||
<li>Each document chunk is converted to a 768-dimensional vector embedding that captures semantic meaning</li>
|
||||
<li>Queries are also converted to embeddings and matched against document vectors using similarity search</li>
|
||||
<li>Results can be retrieved using pure semantic search or hybrid BM25 search combining keywords and semantics</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="info-section">
|
||||
<h2>RAG Workflow Integration</h2>
|
||||
<p>
|
||||
This UI allows you to <strong>test the same queries that Large Language Models (LLMs) would use</strong> in a
|
||||
Retrieval-Augmented Generation (RAG) workflow. When an AI assistant needs to answer questions about your data:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Step 1:</strong> The assistant converts your question into a search query</li>
|
||||
<li><strong>Step 2:</strong> The MCP server retrieves relevant document chunks using semantic search</li>
|
||||
<li><strong>Step 3:</strong> Retrieved context is passed to the LLM to generate an informed answer</li>
|
||||
</ul>
|
||||
|
||||
<!-- RAG Workflow Diagram -->
|
||||
<div style="background: var(--color-main-background); border: 2px solid var(--color-primary-element); border-radius: var(--border-radius-large); padding: 24px; margin: 24px 0; font-family: 'SFMono-Regular', 'Consolas', 'Liberation Mono', 'Menlo', monospace; font-size: 13px; line-height: 1.8; overflow-x: auto;">
|
||||
<div style="text-align: center; font-weight: 600; margin-bottom: 16px; color: var(--color-primary-element); font-size: 14px;">
|
||||
MCP Sampling RAG Workflow
|
||||
</div>
|
||||
<pre style="margin: 0; color: var(--color-main-text);">
|
||||
┌─────────────────┐
|
||||
│ <strong>MCP Client</strong> │ User asks: "What are health benefits of coffee?"
|
||||
│ (Claude Code) │
|
||||
└────────┬────────┘
|
||||
│ (1) User question
|
||||
↓
|
||||
┌────────────────────────────────────────────────────────────────────────┐
|
||||
│ <strong>Nextcloud MCP Server</strong> │
|
||||
│ ┌──────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ <strong>nc_semantic_search_answer</strong> Tool (MCP Sampling-enabled) │ │
|
||||
│ │ │ │
|
||||
│ │ (2) Semantic Search │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ Query: "health benefits of coffee" │ │ │
|
||||
│ │ │ → Convert to 768D vector embedding │ │ │
|
||||
│ │ │ → Search Qdrant (BM25 Hybrid + RRF fusion) │ │ │
|
||||
│ │ │ → Retrieve top 5 relevant document chunks │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ (3) Construct Prompt with Context │ │
|
||||
│ │ ┌────────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ "What are health benefits of coffee? │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Documents: │ │ │
|
||||
│ │ │ - [MED-2155] Effects of habitual coffee consumption...│ │ │
|
||||
│ │ │ - [MED-1646] Beverage consumption guidance... │ │ │
|
||||
│ │ │ - [MED-1627] Coffee and depression risk... │ │ │
|
||||
│ │ │ ... │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ Provide answer with citations." │ │ │
|
||||
│ │ └────────────────────────────────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ (4) MCP Sampling Request │ │
|
||||
│ │ ─────────────────────────────────────────────────────────────> │ │
|
||||
│ └──────────────────────────────────────────────────────────────────┘ │
|
||||
└────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ Sampling request with prompt + context
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ <strong>MCP Client</strong> │ (5) Client's LLM generates answer using retrieved context
|
||||
│ (Claude) │ → "Coffee consumption (2-3 cups/day) is associated with
|
||||
└────────┬────────┘ reduced risk of type 2 diabetes, cardiovascular disease,
|
||||
│ and improved liver health (Document 1, 2)..."
|
||||
│
|
||||
│ (6) Answer with citations
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ User │ Receives comprehensive answer with source citations
|
||||
└─────────────────┘</pre>
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 16px;">
|
||||
<strong>Key Point:</strong> The MCP server retrieves context but doesn't generate answers itself.
|
||||
Through <strong>MCP sampling</strong>, it requests the client's LLM to generate responses, giving users
|
||||
full control over which model is used and ensuring all processing happens client-side.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
By using this interface, you can preview search results, understand relevance scores, and verify
|
||||
that the system retrieves the right information before it reaches the LLM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature Cards -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="/app/user-info" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/app/user-info#vector-sync" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,18A6,6 0 0,1 6,12C6,11 6.25,10.03 6.7,9.2L5.24,7.74C4.46,8.97 4,10.43 4,12A8,8 0 0,0 12,20V23L16,19L12,15M12,4V1L8,5L12,9V6A6,6 0 0,1 18,12C18,13 17.75,13.97 17.3,14.8L18.76,16.26C19.54,15.03 20,13.57 20,12A8,8 0 0,0 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Sync Status</h3>
|
||||
<p>
|
||||
Monitor real-time indexing progress with metrics for indexed documents, pending queue,
|
||||
and synchronization status.
|
||||
</p>
|
||||
</a>
|
||||
|
||||
<a href="/app/user-info#vector-viz" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M22,21H2V3H4V19H6V10H10V19H12V6H16V19H18V14H22V21Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Vector Visualization</h3>
|
||||
<p>
|
||||
Interactive search interface with 2D PCA visualization. Compare algorithms,
|
||||
view relevance scores, and explore matched document chunks.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- Vector Sync Disabled Content -->
|
||||
<div class="warning">
|
||||
<h3 style="margin-top: 0;">Vector Sync is Disabled</h3>
|
||||
<p>
|
||||
Semantic search and vector visualization features are currently disabled.
|
||||
To enable these features, set <code>VECTOR_SYNC_ENABLED=true</code> in your environment configuration.
|
||||
</p>
|
||||
<p style="margin-bottom: 0;">
|
||||
<strong>Learn more:</strong>
|
||||
<a href="https://github.com/YOUR_REPO/docs/configuration.md" target="_blank" style="color: inherit; text-decoration: underline;">
|
||||
Configuration Guide
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Limited Feature Card -->
|
||||
<h2>Available Features</h2>
|
||||
<div class="feature-grid">
|
||||
<a href="/app/user-info" class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M12,4A4,4 0 0,1 16,8A4,4 0 0,1 12,12A4,4 0 0,1 8,8A4,4 0 0,1 12,4M12,14C16.42,14 20,15.79 20,18V20H4V18C4,15.79 7.58,14 12,14Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3>User Information</h3>
|
||||
<p>
|
||||
View your authentication details, session information, and IdP profile.
|
||||
Manage background access permissions.
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Documentation Section -->
|
||||
<div class="info-section" style="margin-top: 40px;">
|
||||
<h2>Documentation</h2>
|
||||
<p>
|
||||
For detailed information about configuration, authentication modes, and advanced features,
|
||||
please refer to the project documentation:
|
||||
</p>
|
||||
<ul>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md" target="_blank">Installation Guide</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md" target="_blank">Configuration Options</a></li>
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/authentication.md" target="_blank">Authentication Modes</a></li>
|
||||
{% if vector_sync_enabled %}
|
||||
<li><a href="https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/user-guide/vector-sync-ui.md" target="_blank">Vector Sync UI Guide</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -21,7 +21,6 @@ from typing import Dict, Optional, Tuple
|
||||
import anyio
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||
@@ -104,7 +103,8 @@ class TokenBrokerService:
|
||||
storage: RefreshTokenStorage,
|
||||
oidc_discovery_url: str,
|
||||
nextcloud_host: str,
|
||||
encryption_key: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
cache_ttl: int = 300,
|
||||
cache_early_refresh: int = 30,
|
||||
):
|
||||
@@ -112,23 +112,25 @@ class TokenBrokerService:
|
||||
Initialize the Token Broker Service.
|
||||
|
||||
Args:
|
||||
storage: Database storage for refresh tokens
|
||||
storage: Database storage for refresh tokens (handles encryption internally)
|
||||
oidc_discovery_url: OIDC provider discovery URL
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
client_id: OAuth client ID for token operations
|
||||
client_secret: OAuth client secret for token operations
|
||||
cache_ttl: Cache TTL in seconds (default: 5 minutes)
|
||||
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
|
||||
"""
|
||||
self.storage = storage
|
||||
self.oidc_discovery_url = oidc_discovery_url
|
||||
self.nextcloud_host = nextcloud_host
|
||||
self.fernet = Fernet(
|
||||
encryption_key.encode()
|
||||
if isinstance(encryption_key, str)
|
||||
else encryption_key
|
||||
)
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self.cache = TokenCache(cache_ttl, cache_early_refresh)
|
||||
self._oidc_config = None
|
||||
|
||||
# Per-user locks for token refresh operations (prevents race conditions)
|
||||
self._user_refresh_locks: dict[str, anyio.Lock] = {}
|
||||
self._locks_lock = anyio.Lock() # Protects the locks dict itself
|
||||
self._http_client = None
|
||||
|
||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||
@@ -139,6 +141,24 @@ class TokenBrokerService:
|
||||
)
|
||||
return self._http_client
|
||||
|
||||
async def _get_user_refresh_lock(self, user_id: str) -> anyio.Lock:
|
||||
"""
|
||||
Get or create a lock for a specific user's refresh operations.
|
||||
|
||||
This prevents race conditions when multiple concurrent requests
|
||||
attempt to refresh the same user's token simultaneously.
|
||||
|
||||
Args:
|
||||
user_id: User ID to get lock for
|
||||
|
||||
Returns:
|
||||
anyio.Lock for this user's refresh operations
|
||||
"""
|
||||
async with self._locks_lock:
|
||||
if user_id not in self._user_refresh_locks:
|
||||
self._user_refresh_locks[user_id] = anyio.Lock()
|
||||
return self._user_refresh_locks[user_id]
|
||||
|
||||
async def _get_oidc_config(self) -> dict:
|
||||
"""Get OIDC configuration from discovery endpoint."""
|
||||
if self._oidc_config is None:
|
||||
@@ -148,6 +168,37 @@ class TokenBrokerService:
|
||||
self._oidc_config = response.json()
|
||||
return self._oidc_config
|
||||
|
||||
def _rewrite_token_endpoint(self, token_endpoint: str) -> str:
|
||||
"""Rewrite token endpoint from public URL to internal Docker URL.
|
||||
|
||||
OIDC discovery documents return public URLs (e.g., http://localhost:8080/...)
|
||||
but server-side requests must use internal Docker network (e.g., http://app:80/...).
|
||||
|
||||
Args:
|
||||
token_endpoint: Token endpoint URL from discovery document
|
||||
|
||||
Returns:
|
||||
Rewritten URL using internal Docker host
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||
if not public_issuer:
|
||||
return token_endpoint
|
||||
|
||||
internal_parsed = urlparse(self.nextcloud_host)
|
||||
token_parsed = urlparse(token_endpoint)
|
||||
public_parsed = urlparse(public_issuer)
|
||||
|
||||
if token_parsed.hostname == public_parsed.hostname:
|
||||
# Replace public URL with internal Docker URL
|
||||
rewritten = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
|
||||
logger.info(f"Rewrote token endpoint: {token_endpoint} -> {rewritten}")
|
||||
return rewritten
|
||||
|
||||
return token_endpoint
|
||||
|
||||
async def get_nextcloud_token(self, user_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get a valid Nextcloud access token for the user.
|
||||
@@ -180,9 +231,8 @@ class TokenBrokerService:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Decrypt refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
refresh_token = refresh_data["refresh_token"]
|
||||
|
||||
# Exchange refresh token for new access token
|
||||
access_token, expires_in = await self._refresh_access_token(refresh_token)
|
||||
@@ -271,41 +321,79 @@ class TokenBrokerService:
|
||||
"""
|
||||
# Check cache first (background tokens can be cached)
|
||||
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
|
||||
refresh_in_progress_key = f"{user_id}:refresh_in_progress"
|
||||
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
return cached_token
|
||||
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
# Acquire per-user lock BEFORE refresh operation to prevent race conditions
|
||||
refresh_lock = await self._get_user_refresh_lock(user_id)
|
||||
async with refresh_lock:
|
||||
# Double-check cache after acquiring lock
|
||||
# (another thread may have refreshed while we waited)
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug(
|
||||
f"Token found in cache after lock acquisition for user {user_id}"
|
||||
)
|
||||
return cached_token
|
||||
|
||||
try:
|
||||
# Decrypt refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
|
||||
# Check if another thread is currently refreshing
|
||||
if await self.cache.get(refresh_in_progress_key):
|
||||
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
|
||||
await anyio.sleep(0.1) # Brief wait for in-progress refresh
|
||||
# Check cache one more time after wait
|
||||
cached_token = await self.cache.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug(
|
||||
f"Token refreshed by another thread for user {user_id}"
|
||||
)
|
||||
return cached_token
|
||||
|
||||
# Get token with specific scopes for background operation
|
||||
access_token, expires_in = await self._refresh_access_token_with_scopes(
|
||||
refresh_token, required_scopes
|
||||
)
|
||||
# Mark refresh as in-progress
|
||||
await self.cache.set(refresh_in_progress_key, "true", expires_in=5)
|
||||
|
||||
# Cache the background token
|
||||
await self.cache.set(cache_key, access_token, expires_in)
|
||||
try:
|
||||
# Get stored refresh token
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if not refresh_data:
|
||||
logger.info(f"No refresh token found for user {user_id}")
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
||||
)
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
refresh_token = refresh_data["refresh_token"]
|
||||
|
||||
return access_token
|
||||
# Get token with specific scopes for background operation
|
||||
# Pass user_id to enable refresh token rotation storage
|
||||
access_token, expires_in = await self._refresh_access_token_with_scopes(
|
||||
refresh_token, required_scopes, user_id=user_id
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get background token for user {user_id}: {e}")
|
||||
await self.cache.invalidate(cache_key)
|
||||
return None
|
||||
# Cache the background token
|
||||
await self.cache.set(cache_key, access_token, expires_in)
|
||||
|
||||
async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]:
|
||||
logger.info(
|
||||
f"Generated background token for user {user_id} with scopes: {required_scopes}"
|
||||
)
|
||||
|
||||
return access_token
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Failed to get background token for user {user_id}: {e}",
|
||||
exc_info=True,
|
||||
)
|
||||
await self.cache.invalidate(cache_key)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Always clear the in-progress marker
|
||||
await self.cache.invalidate(refresh_in_progress_key)
|
||||
|
||||
async def _refresh_access_token(
|
||||
self, refresh_token: str, user_id: str | None = None
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Exchange refresh token for new access token.
|
||||
|
||||
@@ -313,20 +401,24 @@ class TokenBrokerService:
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
user_id: If provided, store the rotated refresh token for this user
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Request new access token using refresh token
|
||||
# Include client credentials as required by most OAuth servers
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": "openid profile email notes:read notes:write calendar:read calendar:write",
|
||||
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
response = await client.post(
|
||||
@@ -345,42 +437,69 @@ class TokenBrokerService:
|
||||
access_token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||
|
||||
# Validate audience
|
||||
await self._validate_token_audience(access_token, "nextcloud")
|
||||
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
if user_id and new_refresh_token and new_refresh_token != refresh_token:
|
||||
# Calculate expiry as Unix timestamp (90 days from now)
|
||||
expires_at = int(
|
||||
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||
)
|
||||
await self.storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||
|
||||
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||
|
||||
logger.info(f"Refreshed access token (expires in {expires_in}s)")
|
||||
return access_token, expires_in
|
||||
|
||||
async def _refresh_access_token_with_scopes(
|
||||
self, refresh_token: str, required_scopes: list[str]
|
||||
self, refresh_token: str, required_scopes: list[str], user_id: str | None = None
|
||||
) -> Tuple[str, int]:
|
||||
"""
|
||||
Exchange refresh token for new access token with specific scopes.
|
||||
|
||||
This method implements scope downscoping for least privilege.
|
||||
|
||||
IMPORTANT: Nextcloud OIDC rotates refresh tokens on every use (one-time use).
|
||||
When user_id is provided, this method stores the new refresh token returned
|
||||
by Nextcloud to ensure subsequent refresh operations succeed.
|
||||
|
||||
Args:
|
||||
refresh_token: The refresh token
|
||||
required_scopes: Minimal scopes needed for this operation
|
||||
user_id: If provided, store the rotated refresh token for this user
|
||||
|
||||
Returns:
|
||||
Tuple of (access_token, expires_in_seconds)
|
||||
"""
|
||||
config = await self._get_oidc_config()
|
||||
token_endpoint = config["token_endpoint"]
|
||||
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
|
||||
|
||||
client = await self._get_http_client()
|
||||
|
||||
# Always include basic OpenID scopes
|
||||
scopes = list(set(["openid", "profile", "email"] + required_scopes))
|
||||
# Always include basic OpenID scopes + offline_access to get new refresh token
|
||||
scopes = list(
|
||||
set(["openid", "profile", "email", "offline_access"] + required_scopes)
|
||||
)
|
||||
|
||||
# Request new access token with specific scopes
|
||||
# Include client credentials as required by most OAuth servers
|
||||
data = {
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": refresh_token,
|
||||
"scope": " ".join(scopes),
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
}
|
||||
|
||||
logger.info(
|
||||
f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..."
|
||||
)
|
||||
|
||||
response = await client.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
@@ -391,14 +510,29 @@ class TokenBrokerService:
|
||||
logger.error(
|
||||
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
|
||||
)
|
||||
logger.error(f" client_id used: {self.client_id[:16]}...")
|
||||
raise Exception(f"Token refresh failed: {response.status_code}")
|
||||
|
||||
token_data = response.json()
|
||||
access_token = token_data["access_token"]
|
||||
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
|
||||
|
||||
# Validate audience
|
||||
await self._validate_token_audience(access_token, "nextcloud")
|
||||
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
if user_id and new_refresh_token and new_refresh_token != refresh_token:
|
||||
# Store the new refresh token for future use
|
||||
# Calculate expiry as Unix timestamp (90 days from now)
|
||||
expires_at = int(
|
||||
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||
)
|
||||
await self.storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Stored rotated refresh token for user {user_id}")
|
||||
|
||||
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
|
||||
|
||||
logger.info(
|
||||
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
|
||||
@@ -453,11 +587,8 @@ class TokenBrokerService:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Decrypt current refresh token
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
current_refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
current_refresh_token = refresh_data["refresh_token"]
|
||||
|
||||
# Get OIDC configuration
|
||||
config = await self._get_oidc_config()
|
||||
@@ -486,13 +617,15 @@ class TokenBrokerService:
|
||||
new_refresh_token = token_data.get("refresh_token")
|
||||
|
||||
if new_refresh_token and new_refresh_token != current_refresh_token:
|
||||
# Encrypt and store new refresh token
|
||||
encrypted_new = self.fernet.encrypt(new_refresh_token.encode()).decode()
|
||||
# storage.store_refresh_token() handles encryption internally
|
||||
# Convert datetime to Unix timestamp (int) for database storage
|
||||
expires_at = int(
|
||||
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
|
||||
)
|
||||
await self.storage.store_refresh_token(
|
||||
user_id=user_id,
|
||||
refresh_token=encrypted_new,
|
||||
expires_at=datetime.now(timezone.utc)
|
||||
+ timedelta(days=90), # 90-day expiry
|
||||
refresh_token=new_refresh_token,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
logger.info(f"Rotated master refresh token for user {user_id}")
|
||||
|
||||
@@ -536,11 +669,8 @@ class TokenBrokerService:
|
||||
refresh_data = await self.storage.get_refresh_token(user_id)
|
||||
if refresh_data:
|
||||
try:
|
||||
# Attempt to revoke at IdP
|
||||
encrypted_token = refresh_data["refresh_token"]
|
||||
refresh_token = self.fernet.decrypt(
|
||||
encrypted_token.encode()
|
||||
).decode()
|
||||
# storage.get_refresh_token() returns already-decrypted token
|
||||
refresh_token = refresh_data["refresh_token"]
|
||||
await self._revoke_token_at_idp(refresh_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to revoke at IdP: {e}")
|
||||
|
||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
||||
|
||||
try:
|
||||
# Introspection requires client authentication
|
||||
client_id = self.settings.oidc_client_id
|
||||
client_secret = self.settings.oidc_client_secret
|
||||
assert client_id is not None and client_secret is not None
|
||||
response = await self.http_client.post(
|
||||
self.introspection_uri,
|
||||
data={"token": token},
|
||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
||||
auth=(client_id, client_secret),
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
|
||||
@@ -9,24 +9,38 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup Jinja2 environment for templates
|
||||
_template_dir = Path(__file__).parent / "templates"
|
||||
_jinja_env = Environment(loader=FileSystemLoader(_template_dir))
|
||||
|
||||
async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.AsyncClient:
|
||||
"""Get an authenticated HTTP client for user info page operations.
|
||||
|
||||
async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudClient:
|
||||
"""Get an authenticated Nextcloud client for user info page operations.
|
||||
|
||||
This is a shared helper for authenticated routes that need to access
|
||||
Nextcloud APIs. It handles both BasicAuth and OAuth authentication modes.
|
||||
|
||||
Args:
|
||||
request: Starlette request object
|
||||
|
||||
Returns:
|
||||
Authenticated httpx.AsyncClient
|
||||
Authenticated NextcloudClient
|
||||
|
||||
Raises:
|
||||
RuntimeError: If credentials/session not configured
|
||||
"""
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
@@ -39,11 +53,15 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.Asyn
|
||||
if not all([nextcloud_host, username, password]):
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
return httpx.AsyncClient(
|
||||
from httpx import BasicAuth
|
||||
|
||||
assert nextcloud_host is not None
|
||||
assert username is not None
|
||||
assert password is not None
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
timeout=30.0,
|
||||
username=username,
|
||||
auth=BasicAuth(username, password),
|
||||
)
|
||||
|
||||
# OAuth mode - get token from session
|
||||
@@ -58,15 +76,14 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> httpx.Asyn
|
||||
raise RuntimeError("No access token found in session")
|
||||
|
||||
access_token = token_data["access_token"]
|
||||
username = token_data.get("username")
|
||||
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
|
||||
|
||||
if not nextcloud_host:
|
||||
raise RuntimeError("Nextcloud host not configured")
|
||||
if not nextcloud_host or not username:
|
||||
raise RuntimeError("Nextcloud host or username not configured")
|
||||
|
||||
return httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
timeout=30.0,
|
||||
return NextcloudClient.from_token(
|
||||
base_url=nextcloud_host, token=access_token, username=username
|
||||
)
|
||||
|
||||
|
||||
@@ -417,10 +434,10 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||
|
||||
# Get authenticated HTTP client
|
||||
http_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, http_client)
|
||||
await http_client.aclose()
|
||||
# Get authenticated Nextcloud client
|
||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||
await nc_client.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to check admin status: {e}")
|
||||
# Default to not admin if check fails
|
||||
@@ -431,51 +448,14 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
login_url = str(request.url_for("oauth_login")) if oauth_ctx else "/oauth/login"
|
||||
|
||||
error_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - Nextcloud MCP Server</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
h1 {{
|
||||
color: #d32f2f;
|
||||
margin-top: 0;
|
||||
}}
|
||||
.error {{
|
||||
background-color: #ffebee;
|
||||
border-left: 4px solid #d32f2f;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Error Retrieving User Info</h1>
|
||||
<div class="error">
|
||||
<strong>Error:</strong> {user_context["error"]}
|
||||
</div>
|
||||
<p><a href="{login_url}">Login again</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=error_html)
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(
|
||||
error_title="Error Retrieving User Info",
|
||||
error_message=user_context["error"],
|
||||
login_url=login_url,
|
||||
)
|
||||
)
|
||||
|
||||
# Build HTML response
|
||||
auth_mode = user_context.get("auth_mode", "unknown")
|
||||
@@ -654,404 +634,26 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
||||
</div>
|
||||
"""
|
||||
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Nextcloud MCP Server</title>
|
||||
# Check if vector sync is enabled (needed for Welcome tab)
|
||||
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
|
||||
|
||||
<!-- htmx for dynamic loading -->
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
|
||||
<!-- Alpine.js for tab state management -->
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Plotly.js for vector visualization -->
|
||||
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js"></script>
|
||||
|
||||
<!-- Vector visualization app (Alpine.js component) -->
|
||||
<script>
|
||||
function vizApp() {{
|
||||
return {{
|
||||
query: '',
|
||||
algorithm: 'bm25_hybrid',
|
||||
showAdvanced: false,
|
||||
docTypes: [''], // Default to "All Types"
|
||||
limit: 50,
|
||||
scoreThreshold: 0.0,
|
||||
loading: false,
|
||||
results: [],
|
||||
|
||||
async executeSearch() {{
|
||||
this.loading = true;
|
||||
this.results = [];
|
||||
|
||||
try {{
|
||||
const params = new URLSearchParams({{
|
||||
query: this.query,
|
||||
algorithm: this.algorithm,
|
||||
limit: this.limit,
|
||||
score_threshold: this.scoreThreshold,
|
||||
}});
|
||||
|
||||
// Add doc_types parameter (filter out empty string for "All Types")
|
||||
const selectedTypes = this.docTypes.filter(t => t !== '');
|
||||
if (selectedTypes.length > 0) {{
|
||||
params.append('doc_types', selectedTypes.join(','));
|
||||
}}
|
||||
|
||||
const response = await fetch(`/app/vector-viz/search?${{params}}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {{
|
||||
this.results = data.results;
|
||||
this.renderPlot(data.coordinates_2d, data.results);
|
||||
}} else {{
|
||||
alert('Search failed: ' + data.error);
|
||||
}}
|
||||
}} catch (error) {{
|
||||
alert('Error: ' + error.message);
|
||||
}} finally {{
|
||||
this.loading = false;
|
||||
}}
|
||||
}},
|
||||
|
||||
renderPlot(coordinates, results) {{
|
||||
// Calculate score range for auto-scaling
|
||||
const scores = results.map(r => r.score);
|
||||
const minScore = Math.min(...scores);
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
const trace = {{
|
||||
x: coordinates.map(c => c[0]),
|
||||
y: coordinates.map(c => c[1]),
|
||||
mode: 'markers',
|
||||
type: 'scatter',
|
||||
text: results.map(r => `${{r.title}}<br>Score: ${{r.score.toFixed(3)}}`),
|
||||
marker: {{
|
||||
// Multi-channel encoding: size + opacity + color for visual hierarchy
|
||||
// Power scaling (score^2) amplifies visual differences dramatically
|
||||
// score=0.0 → 6px, score=0.5 → 9.5px, score=1.0 → 20px
|
||||
size: results.map(r => 6 + (Math.pow(r.score, 2) * 14)),
|
||||
// Linear opacity scaling (0.2-1.0 range keeps all points visible)
|
||||
opacity: results.map(r => 0.2 + (r.score * 0.8)),
|
||||
// Color gradient shows score
|
||||
color: scores,
|
||||
colorscale: 'Viridis',
|
||||
showscale: true,
|
||||
colorbar: {{ title: 'Relative Score' }},
|
||||
// Scores are normalized 0-1 within result set
|
||||
cmin: 0,
|
||||
cmax: 1
|
||||
}}
|
||||
}};
|
||||
|
||||
const layout = {{
|
||||
title: `Vector Space (PCA 2D) - ${{results.length}} results`,
|
||||
xaxis: {{ title: 'PC1' }},
|
||||
yaxis: {{ title: 'PC2' }},
|
||||
hovermode: 'closest',
|
||||
height: 600
|
||||
}};
|
||||
|
||||
Plotly.newPlot('viz-plot', [trace], layout);
|
||||
}},
|
||||
|
||||
getNextcloudUrl(result) {{
|
||||
// Generate Nextcloud URL based on document type
|
||||
// Use the actual Nextcloud host (port 8080), not the MCP server
|
||||
const baseUrl = '{nextcloud_host_for_links}';
|
||||
|
||||
switch (result.doc_type) {{
|
||||
case 'note':
|
||||
return `${{baseUrl}}/apps/notes/note/${{result.id}}`;
|
||||
case 'file':
|
||||
return `${{baseUrl}}/apps/files/?fileId=${{result.id}}`;
|
||||
case 'calendar':
|
||||
return `${{baseUrl}}/apps/calendar`;
|
||||
case 'contact':
|
||||
return `${{baseUrl}}/apps/contacts`;
|
||||
case 'deck':
|
||||
return `${{baseUrl}}/apps/deck`;
|
||||
default:
|
||||
return `${{baseUrl}}`;
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
max-width: 900px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.container {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
min-height: calc(100vh - 200px);
|
||||
}}
|
||||
h1 {{
|
||||
color: #0082c9;
|
||||
margin-top: 0;
|
||||
border-bottom: 2px solid #0082c9;
|
||||
padding-bottom: 10px;
|
||||
}}
|
||||
h2 {{
|
||||
color: #333;
|
||||
margin-top: 20px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-bottom: 5px;
|
||||
}}
|
||||
|
||||
/* Tab navigation */
|
||||
.tabs {{
|
||||
display: flex;
|
||||
gap: 0;
|
||||
margin: 20px 0 0 0;
|
||||
border-bottom: 2px solid #e0e0e0;
|
||||
}}
|
||||
.tab {{
|
||||
padding: 12px 24px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}}
|
||||
.tab:hover {{
|
||||
color: #0082c9;
|
||||
background-color: #f5f5f5;
|
||||
}}
|
||||
.tab.active {{
|
||||
color: #0082c9;
|
||||
border-bottom-color: #0082c9;
|
||||
}}
|
||||
|
||||
/* Tab content - use grid to overlay panes */
|
||||
.tab-content {{
|
||||
padding: 20px 0;
|
||||
display: grid;
|
||||
}}
|
||||
|
||||
/* Tab panes - all occupy the same grid cell to overlay */
|
||||
.tab-pane {{
|
||||
grid-area: 1 / 1;
|
||||
}}
|
||||
|
||||
/* Tables */
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}}
|
||||
td {{
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}}
|
||||
td:first-child {{
|
||||
width: 200px;
|
||||
color: #666;
|
||||
}}
|
||||
code {{
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
}}
|
||||
|
||||
/* Badges */
|
||||
.badge {{
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
}}
|
||||
.badge-oauth {{
|
||||
background-color: #4caf50;
|
||||
color: white;
|
||||
}}
|
||||
.badge-basic {{
|
||||
background-color: #2196f3;
|
||||
color: white;
|
||||
}}
|
||||
|
||||
/* Messages */
|
||||
.warning {{
|
||||
background-color: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #856404;
|
||||
}}
|
||||
.info-message {{
|
||||
background-color: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
color: #1565c0;
|
||||
}}
|
||||
|
||||
/* Buttons */
|
||||
.button {{
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background-color: #d32f2f;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.button:hover {{
|
||||
background-color: #b71c1c;
|
||||
}}
|
||||
.button-primary {{
|
||||
background-color: #0082c9;
|
||||
}}
|
||||
.button-primary:hover {{
|
||||
background-color: #006ba3;
|
||||
}}
|
||||
|
||||
/* Logout section */
|
||||
.logout {{
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}}
|
||||
|
||||
/* Smooth htmx content swaps */
|
||||
.htmx-swapping {{
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease-out;
|
||||
}}
|
||||
|
||||
/* Smooth htmx content settling */
|
||||
.htmx-settling {{
|
||||
opacity: 1;
|
||||
transition: opacity 200ms ease-in;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container" x-data="{{ activeTab: 'user-info' }}">
|
||||
<h1>Nextcloud MCP Server</h1>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'user-info' ? 'active' : ''"
|
||||
@click="activeTab = 'user-info'">
|
||||
User Info
|
||||
</button>
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'vector-sync' ? 'active' : ''"
|
||||
@click="activeTab = 'vector-sync'">
|
||||
Vector Sync
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'vector-viz' ? 'active' : ''"
|
||||
@click="activeTab = 'vector-viz'">
|
||||
Vector Viz
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
{
|
||||
""
|
||||
if not show_webhooks_tab
|
||||
else '''
|
||||
<button
|
||||
class="tab"
|
||||
:class="activeTab === 'webhooks' ? 'active' : ''"
|
||||
@click="activeTab = 'webhooks'">
|
||||
Webhooks
|
||||
</button>
|
||||
'''
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content">
|
||||
<!-- User Info Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'user-info'" x-transition.opacity.duration.150ms>
|
||||
{user_info_tab_html}
|
||||
</div>
|
||||
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else f'''
|
||||
<!-- Vector Sync Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'vector-sync'" x-transition.opacity.duration.150ms>
|
||||
{vector_sync_tab_html}
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
|
||||
{
|
||||
""
|
||||
if not show_vector_sync_tab
|
||||
else '''
|
||||
<!-- Vector Viz Tab -->
|
||||
<div class="tab-pane" x-show="activeTab === 'vector-viz'" x-transition.opacity.duration.150ms>
|
||||
<div hx-get="/app/vector-viz" hx-trigger="load" hx-swap="outerHTML">
|
||||
<p style="color: #999;">Loading vector visualization...</p>
|
||||
</div>
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
|
||||
{
|
||||
""
|
||||
if not show_webhooks_tab
|
||||
else f'''
|
||||
<!-- Webhooks Tab (admin-only, loaded dynamically) -->
|
||||
<div class="tab-pane" x-show="activeTab === 'webhooks'" x-transition.opacity.duration.150ms>
|
||||
{webhooks_tab_html}
|
||||
</div>
|
||||
'''
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
f'<div class="logout"><a href="{logout_url}" class="button">Logout</a></div>'
|
||||
if auth_mode == "oauth"
|
||||
else ""
|
||||
}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return HTMLResponse(content=html_content)
|
||||
# Render template
|
||||
template = _jinja_env.get_template("user_info.html")
|
||||
return HTMLResponse(
|
||||
content=template.render(
|
||||
user_info_tab_html=user_info_tab_html,
|
||||
vector_sync_tab_html=vector_sync_tab_html,
|
||||
webhooks_tab_html=webhooks_tab_html,
|
||||
show_vector_sync_tab=show_vector_sync_tab,
|
||||
show_webhooks_tab=show_webhooks_tab,
|
||||
logout_url=logout_url if auth_mode == "oauth" else None,
|
||||
nextcloud_host_for_links=nextcloud_host_for_links,
|
||||
# Additional context for Welcome tab
|
||||
vector_sync_enabled=vector_sync_enabled,
|
||||
username=username,
|
||||
auth_mode=auth_mode,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
@@ -1071,17 +673,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
oauth_ctx = getattr(request.app.state, "oauth_context", None)
|
||||
|
||||
if not oauth_ctx:
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>OAuth mode not enabled</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message="OAuth mode not enabled",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1089,17 +686,12 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
session_id = request.cookies.get("mcp_session")
|
||||
|
||||
if not storage or not session_id:
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Session not found</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message="Session not found",
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
@@ -1112,57 +704,26 @@ async def revoke_session(request: Request) -> HTMLResponse:
|
||||
# Redirect back to user page
|
||||
user_page_url = str(request.url_for("user_info_html"))
|
||||
|
||||
template = _jinja_env.get_template("success.html")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="refresh" content="2;url={user_page_url}">
|
||||
<title>Background Access Revoked</title>
|
||||
<style>
|
||||
body {{
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}}
|
||||
.success {{
|
||||
background-color: #e8f5e9;
|
||||
border: 2px solid #4caf50;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
h1 {{
|
||||
color: #4caf50;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="success">
|
||||
<h1>✓ Background Access Revoked</h1>
|
||||
<p>Your refresh token has been deleted successfully.</p>
|
||||
<p>Browser session remains active.</p>
|
||||
<p>Redirecting back to user page...</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
content=template.render(
|
||||
success_title="✓ Background Access Revoked",
|
||||
success_messages=[
|
||||
"Your refresh token has been deleted successfully.",
|
||||
"Browser session remains active.",
|
||||
],
|
||||
redirect_url=user_page_url,
|
||||
redirect_delay=2,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke background access: {e}")
|
||||
template = _jinja_env.get_template("error.html")
|
||||
return HTMLResponse(
|
||||
f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Error</title></head>
|
||||
<body>
|
||||
<h1>Error</h1>
|
||||
<p>Failed to revoke background access: {e}</p>
|
||||
</body>
|
||||
</html>
|
||||
""",
|
||||
content=template.render(
|
||||
error_title="Error",
|
||||
error_message=f"Failed to revoke background access: {e}",
|
||||
),
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -1,33 +1,42 @@
|
||||
"""Vector visualization routes for testing search algorithms.
|
||||
|
||||
Provides a web UI for users to test different search algorithms on their own
|
||||
indexed documents and visualize results in 2D space using PCA.
|
||||
indexed documents and visualize results in 3D space using PCA.
|
||||
|
||||
All processing happens server-side following ADR-012:
|
||||
- Search execution via shared search/algorithms.py
|
||||
- PCA dimensionality reduction (768-dim → 2D)
|
||||
- Only 2D coordinates + metadata sent to client
|
||||
- Bandwidth-efficient (2 floats per doc vs 768)
|
||||
- Query embedding generation
|
||||
- PCA dimensionality reduction (768-dim → 3D)
|
||||
- Only 3D coordinates + metadata sent to client
|
||||
- Bandwidth-efficient (3 floats per doc vs 768)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from starlette.authentication import requires
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search import (
|
||||
BM25HybridSearchAlgorithm,
|
||||
SemanticSearchAlgorithm,
|
||||
)
|
||||
from nextcloud_mcp_server.vector.pca import PCA
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Setup Jinja2 environment for templates
|
||||
_template_dir = Path(__file__).parent / "templates"
|
||||
_jinja_env = Environment(loader=FileSystemLoader(_template_dir))
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def vector_visualization_html(request: Request) -> HTMLResponse:
|
||||
@@ -63,270 +72,28 @@ async def vector_visualization_html(request: Request) -> HTMLResponse:
|
||||
else "unknown"
|
||||
)
|
||||
|
||||
html_content = f"""
|
||||
<style>
|
||||
.viz-card {{
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}}
|
||||
.viz-controls {{
|
||||
margin-bottom: 20px;
|
||||
}}
|
||||
.viz-control-row {{
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr auto;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
align-items: end;
|
||||
}}
|
||||
.viz-control-group {{
|
||||
margin-bottom: 15px;
|
||||
}}
|
||||
.viz-control-group label {{
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}}
|
||||
.viz-control-group input[type="text"],
|
||||
.viz-control-group input[type="number"],
|
||||
.viz-control-group select {{
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}}
|
||||
.viz-control-group input[type="range"] {{
|
||||
width: 100%;
|
||||
}}
|
||||
.viz-control-group select[multiple] {{
|
||||
min-height: 100px;
|
||||
}}
|
||||
.viz-weight-display {{
|
||||
display: inline-block;
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
color: #666;
|
||||
}}
|
||||
.viz-btn {{
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}}
|
||||
.viz-btn:hover {{
|
||||
background: #0052a3;
|
||||
}}
|
||||
.viz-btn-secondary {{
|
||||
background: #6c757d;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
margin-bottom: 12px;
|
||||
}}
|
||||
.viz-btn-secondary:hover {{
|
||||
background: #5a6268;
|
||||
}}
|
||||
#viz-plot-container {{
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
position: relative;
|
||||
}}
|
||||
#viz-plot {{
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}}
|
||||
.viz-loading {{
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
}}
|
||||
.viz-loading-overlay {{
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
color: #666;
|
||||
}}
|
||||
.viz-no-results {{
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
}}
|
||||
.viz-advanced-section {{
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #dee2e6;
|
||||
}}
|
||||
.viz-advanced-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
}}
|
||||
.viz-info-box {{
|
||||
background: #e3f2fd;
|
||||
border-left: 4px solid #2196f3;
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 14px;
|
||||
}}
|
||||
</style>
|
||||
|
||||
<div x-data="vizApp()">
|
||||
<div class="viz-card">
|
||||
<h2>Vector Visualization</h2>
|
||||
<div class="viz-info-box">
|
||||
Testing search algorithms on your indexed documents. User: <strong>{username}</strong>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="executeSearch">
|
||||
<div class="viz-controls">
|
||||
<!-- Main Controls -->
|
||||
<div class="viz-control-group">
|
||||
<label>Search Query</label>
|
||||
<input type="text" x-model="query" placeholder="Enter search query..." required />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-row">
|
||||
<div class="viz-control-group" style="margin-bottom: 0;">
|
||||
<label>Algorithm</label>
|
||||
<select x-model="algorithm">
|
||||
<option value="semantic">Semantic (Dense Vectors)</option>
|
||||
<option value="bm25_hybrid" selected>BM25 Hybrid (Dense + Sparse RRF)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="submit" class="viz-btn" style="width: 100%;">Search & Visualize</button>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; align-items: flex-end;">
|
||||
<button type="button" class="viz-btn-secondary" @click="showAdvanced = !showAdvanced" style="white-space: nowrap;">
|
||||
<span x-text="showAdvanced ? 'Hide Advanced' : 'Advanced'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Options (Collapsible) -->
|
||||
<div class="viz-advanced-section" x-show="showAdvanced" x-transition.opacity.duration.200ms>
|
||||
<h3 style="margin-top: 0; margin-bottom: 16px; font-size: 16px;">Advanced Options</h3>
|
||||
|
||||
<div class="viz-advanced-grid">
|
||||
<div class="viz-control-group">
|
||||
<label>Document Types</label>
|
||||
<select x-model="docTypes" multiple>
|
||||
<option value="">All Types (cross-app search)</option>
|
||||
<option value="note">Notes</option>
|
||||
<option value="file">Files</option>
|
||||
<option value="calendar">Calendar Events</option>
|
||||
<option value="contact">Contacts</option>
|
||||
<option value="deck">Deck Cards</option>
|
||||
</select>
|
||||
<small style="color: #666; display: block; margin-top: 4px;">
|
||||
Hold Ctrl/Cmd to select multiple
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="viz-control-group">
|
||||
<label>Score Threshold (Semantic/Hybrid)</label>
|
||||
<input type="number" x-model.number="scoreThreshold" min="0" max="1" step="0.1" />
|
||||
</div>
|
||||
|
||||
<div class="viz-control-group">
|
||||
<label>Result Limit</label>
|
||||
<input type="number" x-model.number="limit" min="1" max="100" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info: BM25 Hybrid uses native RRF fusion (no manual weights) -->
|
||||
<div x-show="algorithm === 'bm25_hybrid'" style="margin-top: 16px; padding: 12px; background: #e9ecef; border-radius: 4px;">
|
||||
<p style="margin: 0; font-size: 14px; color: #666;">
|
||||
<strong>BM25 Hybrid Search:</strong> Uses Qdrant's native Reciprocal Rank Fusion (RRF)
|
||||
to automatically combine dense semantic vectors with sparse BM25 keyword vectors.
|
||||
No manual weight tuning required.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="viz-card">
|
||||
<div id="viz-plot-container">
|
||||
<div x-show="loading" class="viz-loading-overlay" x-transition.opacity.duration.200ms>
|
||||
Executing search and computing PCA projection...
|
||||
</div>
|
||||
<div id="viz-plot" x-show="!loading" x-transition.opacity.duration.200ms></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="viz-card">
|
||||
<h3>Search Results (<span x-text="loading ? '...' : results.length"></span>)</h3>
|
||||
|
||||
<div x-show="loading" class="viz-loading" x-transition.opacity.duration.200ms>
|
||||
Loading results...
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && results.length === 0" class="viz-no-results" x-transition.opacity.duration.200ms>
|
||||
No results found. Try a different query or adjust your search parameters.
|
||||
</div>
|
||||
|
||||
<template x-if="!loading && results.length > 0">
|
||||
<div x-transition.opacity.duration.200ms>
|
||||
<template x-for="result in results" :key="result.id">
|
||||
<div style="padding: 12px; border-bottom: 1px solid #eee;">
|
||||
<a :href="getNextcloudUrl(result)" target="_blank" style="font-weight: 500; color: #0066cc; text-decoration: none;">
|
||||
<span x-text="result.title"></span>
|
||||
</a>
|
||||
<div style="font-size: 14px; color: #666; margin-top: 4px;" x-text="result.excerpt"></div>
|
||||
<div style="font-size: 12px; color: #999; margin-top: 4px;">
|
||||
Score: <span x-text="result.score.toFixed(3)"></span> |
|
||||
Type: <span x-text="result.doc_type"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
# Load and render template
|
||||
template = _jinja_env.get_template("vector_viz.html")
|
||||
html_content = template.render(username=username)
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
"""Execute server-side search and return 2D coordinates + results.
|
||||
"""Execute server-side search and return 3D coordinates + results.
|
||||
|
||||
All processing happens server-side:
|
||||
1. Execute search via shared algorithm module
|
||||
2. Fetch matching vectors from Qdrant
|
||||
3. Apply PCA reduction (768-dim → 2D)
|
||||
4. Return coordinates + metadata only
|
||||
2. Generate query embedding
|
||||
3. Fetch matching vectors from Qdrant
|
||||
4. Apply PCA reduction (768-dim → 3D) to query + documents
|
||||
5. Return coordinates + metadata only
|
||||
|
||||
Args:
|
||||
request: Starlette request with query parameters
|
||||
|
||||
Returns:
|
||||
JSON response with coordinates_2d and results
|
||||
JSON response with coordinates_3d and results (including query point)
|
||||
"""
|
||||
settings = get_settings()
|
||||
|
||||
@@ -352,6 +119,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
algorithm = request.query_params.get("algorithm", "bm25_hybrid")
|
||||
limit = int(request.query_params.get("limit", "50"))
|
||||
score_threshold = float(request.query_params.get("score_threshold", "0.0"))
|
||||
fusion = request.query_params.get("fusion", "rrf") # Default to RRF
|
||||
|
||||
# Parse doc_types (comma-separated list, None = all types)
|
||||
doc_types_param = request.query_params.get("doc_types", "")
|
||||
@@ -359,7 +127,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
|
||||
logger.info(
|
||||
f"Viz search: user={username}, query='{query}', "
|
||||
f"algorithm={algorithm}, limit={limit}, doc_types={doc_types}"
|
||||
f"algorithm={algorithm}, fusion={fusion}, limit={limit}, doc_types={doc_types}"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -372,12 +140,17 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
|
||||
async with await _get_authenticated_client_for_userinfo(request) as http_client: # noqa: F841
|
||||
with trace_operation("vector_viz.get_auth_client"):
|
||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||
|
||||
async with auth_client_ctx as nc_client: # noqa: F841
|
||||
# Create search algorithm (no client needed - verification removed)
|
||||
if algorithm == "semantic":
|
||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||
elif algorithm == "bm25_hybrid":
|
||||
search_algo = BM25HybridSearchAlgorithm(score_threshold=score_threshold)
|
||||
search_algo = BM25HybridSearchAlgorithm(
|
||||
score_threshold=score_threshold, fusion=fusion
|
||||
)
|
||||
else:
|
||||
return JSONResponse(
|
||||
{"success": False, "error": f"Unknown algorithm: {algorithm}"},
|
||||
@@ -390,24 +163,40 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
all_results = []
|
||||
if doc_types is None or len(doc_types) == 0:
|
||||
# Cross-app search - search all indexed types
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": "all",
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
doc_type=None, # Search all types
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
else:
|
||||
# Search each document type and combine
|
||||
for doc_type in doc_types:
|
||||
with trace_operation(
|
||||
"vector_viz.search_execute",
|
||||
attributes={
|
||||
"search.algorithm": algorithm,
|
||||
"search.limit": limit * 2,
|
||||
"search.doc_type": doc_type,
|
||||
},
|
||||
):
|
||||
unverified_results = await search_algo.search(
|
||||
query=query,
|
||||
user_id=username,
|
||||
limit=limit * 2, # Buffer for verification filtering
|
||||
doc_type=doc_type,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
all_results.extend(unverified_results)
|
||||
# Sort by score before verification
|
||||
all_results.sort(key=lambda r: r.score, reverse=True)
|
||||
@@ -418,89 +207,87 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
search_results = all_results[:limit]
|
||||
search_duration = time.perf_counter() - search_start
|
||||
|
||||
# Normalize scores relative to this result set for better visualization
|
||||
# Store original scores and normalize for visualization
|
||||
# (best result = 1.0, worst result = 0.0 within THIS result set)
|
||||
# This makes visual encoding meaningful regardless of RRF normalization
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
with trace_operation(
|
||||
"vector_viz.score_normalize",
|
||||
attributes={"normalize.num_results": len(search_results)},
|
||||
):
|
||||
if search_results:
|
||||
scores = [r.score for r in search_results]
|
||||
min_score, max_score = min(scores), max(scores)
|
||||
score_range = max_score - min_score if max_score > min_score else 1.0
|
||||
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
logger.info(
|
||||
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
|
||||
f"→ [0.0, 1.0]"
|
||||
)
|
||||
|
||||
# Rescale each result's score to 0-1 within this result set
|
||||
for r in search_results:
|
||||
r.score = (r.score - min_score) / score_range
|
||||
# Store original score and rescale to 0-1 for visualization
|
||||
for r in search_results:
|
||||
# Store original score before normalization
|
||||
r.original_score = r.score
|
||||
# Rescale for visual encoding
|
||||
r.score = (r.score - min_score) / score_range
|
||||
|
||||
if not search_results:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": [],
|
||||
"coordinates_2d": [],
|
||||
"coordinates_3d": [],
|
||||
"query_coords": [],
|
||||
"message": "No results found",
|
||||
}
|
||||
)
|
||||
|
||||
# Fetch vectors for matching results from Qdrant
|
||||
# Fetch vectors for specific matching chunks from Qdrant using batch retrieve
|
||||
vector_fetch_start = time.perf_counter()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
doc_ids = [r.id for r in search_results]
|
||||
|
||||
# Retrieve vectors for the matching documents
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchAny
|
||||
with trace_operation("vector_viz.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="doc_id",
|
||||
match=MatchAny(any=[str(doc_id) for doc_id in doc_ids]),
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match={"value": username},
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=len(doc_ids) * 2, # Account for multiple chunks per doc
|
||||
with_vectors=["dense"], # Only fetch dense vectors for visualization
|
||||
with_payload=["doc_id"], # Need doc_id to map vectors to results
|
||||
)
|
||||
chunk_vectors_map = {} # Map (doc_id, chunk_start, chunk_end) -> vector
|
||||
|
||||
points = points_response[0]
|
||||
# Collect point IDs from search results for batch retrieval
|
||||
# point_id is the Qdrant internal ID returned by search algorithms
|
||||
point_ids = [r.point_id for r in search_results if r.point_id]
|
||||
|
||||
if not points:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": [],
|
||||
"coordinates_2d": [],
|
||||
"message": "No vectors found for results",
|
||||
}
|
||||
)
|
||||
if point_ids:
|
||||
# Single batch retrieve call instead of N sequential scroll calls
|
||||
# This is ~50x faster for 50 results (1 HTTP request vs 50)
|
||||
with trace_operation(
|
||||
"vector_viz.vector_retrieve",
|
||||
attributes={"retrieve.num_points": len(point_ids)},
|
||||
):
|
||||
points_response = await qdrant_client.retrieve(
|
||||
collection_name=settings.get_collection_name(),
|
||||
ids=point_ids,
|
||||
with_vectors=["dense"],
|
||||
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
|
||||
)
|
||||
|
||||
# Extract dense vectors (handle both named and unnamed vectors)
|
||||
def extract_dense_vector(point):
|
||||
if point.vector is None:
|
||||
return None
|
||||
# If named vectors (dict), extract "dense"
|
||||
if isinstance(point.vector, dict):
|
||||
return point.vector.get("dense")
|
||||
# If unnamed vector (array), use directly
|
||||
return point.vector
|
||||
# Build chunk_vectors_map from batch response
|
||||
for point in points_response:
|
||||
if point.vector is not None:
|
||||
# Extract dense vector (handle both named and unnamed vectors)
|
||||
if isinstance(point.vector, dict):
|
||||
vector = point.vector.get("dense")
|
||||
else:
|
||||
vector = point.vector
|
||||
|
||||
if vector is not None and point.payload:
|
||||
doc_id = point.payload.get("doc_id")
|
||||
chunk_start = point.payload.get("chunk_start_offset")
|
||||
chunk_end = point.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, chunk_start, chunk_end)
|
||||
chunk_vectors_map[chunk_key] = vector
|
||||
|
||||
vectors = np.array(
|
||||
[v for v in (extract_dense_vector(p) for p in points) if v is not None]
|
||||
)
|
||||
vector_fetch_duration = time.perf_counter() - vector_fetch_start
|
||||
|
||||
if len(vectors) < 2:
|
||||
# Not enough points for PCA
|
||||
if len(chunk_vectors_map) < 2:
|
||||
# Not enough chunks for PCA
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
@@ -511,38 +298,153 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
"title": r.title,
|
||||
"excerpt": r.excerpt,
|
||||
"score": r.score,
|
||||
"metadata": r.metadata,
|
||||
}
|
||||
for r in search_results
|
||||
],
|
||||
"coordinates_2d": [[0, 0]] * len(search_results),
|
||||
"message": "Not enough vectors for PCA",
|
||||
"coordinates_3d": [[0, 0, 0]] * len(search_results),
|
||||
"query_coords": [0, 0, 0],
|
||||
"message": "Not enough chunks for PCA",
|
||||
}
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 2D)
|
||||
# Detect embedding dimension from first available vector
|
||||
embedding_dim = None
|
||||
for vector in chunk_vectors_map.values():
|
||||
if vector is not None:
|
||||
embedding_dim = len(vector)
|
||||
break
|
||||
|
||||
if embedding_dim is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Could not determine embedding dimension",
|
||||
},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
logger.info(f"Detected embedding dimension: {embedding_dim}")
|
||||
|
||||
# Build chunk vectors array in search_results order (1:1 mapping)
|
||||
chunk_vectors = []
|
||||
for result in search_results:
|
||||
chunk_key = (result.id, result.chunk_start_offset, result.chunk_end_offset)
|
||||
if chunk_key in chunk_vectors_map:
|
||||
chunk_vectors.append(chunk_vectors_map[chunk_key])
|
||||
else:
|
||||
# Chunk not found in vectors (shouldn't happen)
|
||||
logger.warning(
|
||||
f"Chunk {chunk_key} not found in fetched vectors, using zero vector"
|
||||
)
|
||||
# Use zero vector as fallback
|
||||
chunk_vectors.append(np.zeros(embedding_dim))
|
||||
|
||||
chunk_vectors = np.array(chunk_vectors)
|
||||
|
||||
# Reuse query embedding from search algorithm (avoids redundant embedding call)
|
||||
query_embed_start = time.perf_counter()
|
||||
if search_algo.query_embedding is not None:
|
||||
query_embedding = search_algo.query_embedding
|
||||
logger.info(
|
||||
f"Reusing query embedding from search algorithm "
|
||||
f"(dimension={len(query_embedding)})"
|
||||
)
|
||||
else:
|
||||
# Fallback: generate embedding if not available from search
|
||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||
query_embed_duration = time.perf_counter() - query_embed_start
|
||||
|
||||
# Combine query vector with chunk vectors for PCA
|
||||
# Query will be the last point in the array
|
||||
all_vectors = np.vstack([chunk_vectors, np.array([query_embedding])])
|
||||
|
||||
# Normalize vectors to unit length (L2 normalization)
|
||||
# This is critical because Qdrant uses COSINE distance, which only measures
|
||||
# vector direction (angle), not magnitude. PCA uses Euclidean distance which
|
||||
# considers both direction and magnitude. By normalizing to unit length,
|
||||
# Euclidean distances in PCA space will match cosine distances.
|
||||
norms = np.linalg.norm(all_vectors, axis=1, keepdims=True)
|
||||
|
||||
# Check for zero-norm vectors (can happen with empty/corrupted embeddings)
|
||||
zero_norm_mask = norms[:, 0] < 1e-10
|
||||
if zero_norm_mask.any():
|
||||
zero_indices = np.where(zero_norm_mask)[0]
|
||||
logger.warning(
|
||||
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices {zero_indices.tolist()}. "
|
||||
"Replacing with small epsilon to avoid division by zero."
|
||||
)
|
||||
# Replace zero norms with small epsilon to avoid NaN
|
||||
norms[zero_norm_mask] = 1e-10
|
||||
|
||||
all_vectors_normalized = all_vectors / norms
|
||||
logger.info(
|
||||
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, "
|
||||
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]"
|
||||
)
|
||||
|
||||
# Apply PCA dimensionality reduction (768-dim → 3D) on normalized vectors
|
||||
# Run in thread pool to avoid blocking the event loop (CPU-bound)
|
||||
pca_start = time.perf_counter()
|
||||
pca = PCA(n_components=2)
|
||||
coords_2d = pca.fit_transform(vectors)
|
||||
|
||||
def _compute_pca(vectors: np.ndarray) -> tuple[np.ndarray, PCA]:
|
||||
pca = PCA(n_components=3)
|
||||
coords = pca.fit_transform(vectors)
|
||||
return coords, pca
|
||||
|
||||
import anyio
|
||||
|
||||
with trace_operation(
|
||||
"vector_viz.pca_compute",
|
||||
attributes={
|
||||
"pca.num_vectors": len(all_vectors_normalized),
|
||||
"pca.embedding_dim": embedding_dim,
|
||||
},
|
||||
):
|
||||
coords_3d, pca = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: _compute_pca(all_vectors_normalized)
|
||||
)
|
||||
pca_duration = time.perf_counter() - pca_start
|
||||
|
||||
# After fit, these attributes are guaranteed to be set
|
||||
assert pca.explained_variance_ratio_ is not None
|
||||
|
||||
# Check for NaN values in PCA output (numerical instability)
|
||||
nan_mask = np.isnan(coords_3d)
|
||||
if nan_mask.any():
|
||||
nan_rows = np.where(nan_mask.any(axis=1))[0]
|
||||
logger.error(
|
||||
f"Found NaN values in PCA output at {len(nan_rows)} points: {nan_rows.tolist()[:10]}. "
|
||||
"Replacing NaN with 0.0 to prevent JSON serialization error."
|
||||
)
|
||||
# Replace NaN with 0 to allow JSON serialization
|
||||
coords_3d = np.nan_to_num(coords_3d, nan=0.0)
|
||||
|
||||
# Split query coords from chunk coords
|
||||
# Round to 2 decimal places for cleaner display
|
||||
query_coords_3d = [
|
||||
round(float(x), 2) for x in coords_3d[-1]
|
||||
] # Last point is query
|
||||
chunk_coords_3d = coords_3d[:-1] # All but last are chunks
|
||||
|
||||
logger.info(
|
||||
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, "
|
||||
f"PC2={pca.explained_variance_ratio_[1]:.3f}"
|
||||
f"PC2={pca.explained_variance_ratio_[1]:.3f}, "
|
||||
f"PC3={pca.explained_variance_ratio_[2]:.3f}"
|
||||
)
|
||||
logger.info(
|
||||
f"Embedding stats: chunks={len(chunk_vectors)}, "
|
||||
f"query_dim={len(query_embedding)}, chunk_vector_dim={chunk_vectors.shape[1] if chunk_vectors.size > 0 else 0}"
|
||||
)
|
||||
|
||||
# Map results to coordinates (use first chunk per document)
|
||||
result_coords = []
|
||||
seen_doc_ids = set()
|
||||
|
||||
for point, coord in zip(points, coords_2d):
|
||||
if point.payload:
|
||||
doc_id = int(point.payload.get("doc_id", 0))
|
||||
if doc_id not in seen_doc_ids and doc_id in doc_ids:
|
||||
seen_doc_ids.add(doc_id)
|
||||
result_coords.append(coord.tolist())
|
||||
# Coordinates already match search_results order (1:1 mapping)
|
||||
result_coords = [
|
||||
[round(float(x), 2) for x in coord] for coord in chunk_coords_3d
|
||||
]
|
||||
|
||||
# Build response
|
||||
response_results = [
|
||||
@@ -551,7 +453,13 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
"doc_type": r.doc_type,
|
||||
"title": r.title,
|
||||
"excerpt": r.excerpt,
|
||||
"score": r.score,
|
||||
"score": r.score, # Normalized score for visual encoding (0-1)
|
||||
"original_score": getattr(
|
||||
r, "original_score", r.score
|
||||
), # Raw score from algorithm
|
||||
"chunk_start_offset": r.chunk_start_offset,
|
||||
"chunk_end_offset": r.chunk_end_offset,
|
||||
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
|
||||
}
|
||||
for r in search_results
|
||||
]
|
||||
@@ -564,26 +472,30 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
f"Viz search timing: total={total_duration * 1000:.1f}ms, "
|
||||
f"search={search_duration * 1000:.1f}ms ({search_duration / total_duration * 100:.1f}%), "
|
||||
f"vector_fetch={vector_fetch_duration * 1000:.1f}ms ({vector_fetch_duration / total_duration * 100:.1f}%), "
|
||||
f"query_embed={query_embed_duration * 1000:.1f}ms ({query_embed_duration / total_duration * 100:.1f}%), "
|
||||
f"pca={pca_duration * 1000:.1f}ms ({pca_duration / total_duration * 100:.1f}%), "
|
||||
f"results={len(search_results)}, vectors={len(vectors)}"
|
||||
f"results={len(search_results)}, chunk_vectors={len(chunk_vectors)}"
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"results": response_results,
|
||||
"coordinates_2d": result_coords[: len(search_results)],
|
||||
"coordinates_3d": result_coords[: len(search_results)],
|
||||
"query_coords": query_coords_3d,
|
||||
"pca_variance": {
|
||||
"pc1": float(pca.explained_variance_ratio_[0]),
|
||||
"pc2": float(pca.explained_variance_ratio_[1]),
|
||||
"pc3": float(pca.explained_variance_ratio_[2]),
|
||||
},
|
||||
"timing": {
|
||||
"total_ms": round(total_duration * 1000, 2),
|
||||
"search_ms": round(search_duration * 1000, 2),
|
||||
"vector_fetch_ms": round(vector_fetch_duration * 1000, 2),
|
||||
"query_embed_ms": round(query_embed_duration * 1000, 2),
|
||||
"pca_ms": round(pca_duration * 1000, 2),
|
||||
"num_results": len(search_results),
|
||||
"num_vectors": len(vectors),
|
||||
"num_chunk_vectors": len(chunk_vectors),
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -594,3 +506,166 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
||||
{"success": False, "error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
|
||||
@requires("authenticated", redirect="oauth_login")
|
||||
async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
||||
"""Fetch chunk text with surrounding context for visualization.
|
||||
|
||||
This endpoint retrieves the matched chunk along with surrounding text
|
||||
to provide context for the search result. Used by the viz pane to
|
||||
display chunks inline.
|
||||
|
||||
Query parameters:
|
||||
doc_type: Document type (e.g., "note")
|
||||
doc_id: Document ID
|
||||
start: Chunk start offset (character position)
|
||||
end: Chunk end offset (character position)
|
||||
context: Characters of context before/after (default: 500)
|
||||
|
||||
Returns:
|
||||
JSON with chunk_text, before_context, after_context, and flags
|
||||
"""
|
||||
try:
|
||||
# Get query parameters
|
||||
doc_type = request.query_params.get("doc_type")
|
||||
doc_id = request.query_params.get("doc_id")
|
||||
start_str = request.query_params.get("start")
|
||||
end_str = request.query_params.get("end")
|
||||
context_chars = int(request.query_params.get("context", "500"))
|
||||
|
||||
# Validate required parameters
|
||||
if not all([doc_type, doc_id, start_str, end_str]):
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": "Missing required parameters: doc_type, doc_id, start, end",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Type assertions - we validated these above
|
||||
assert doc_type is not None
|
||||
assert doc_id is not None
|
||||
assert start_str is not None
|
||||
assert end_str is not None
|
||||
|
||||
start = int(start_str)
|
||||
end = int(end_str)
|
||||
# Convert doc_id to int (all document types use int IDs)
|
||||
doc_id_int = int(doc_id)
|
||||
|
||||
# Get authenticated Nextcloud client
|
||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||
_get_authenticated_client_for_userinfo,
|
||||
)
|
||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||
|
||||
# Use context expansion module to fetch chunk with surrounding context
|
||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||
chunk_context = await get_chunk_with_context(
|
||||
nc_client=nc_client,
|
||||
user_id=request.user.display_name, # User ID from auth
|
||||
doc_id=doc_id_int,
|
||||
doc_type=doc_type,
|
||||
chunk_start=start,
|
||||
chunk_end=end,
|
||||
context_chars=context_chars,
|
||||
)
|
||||
|
||||
# Check if context expansion succeeded
|
||||
if chunk_context is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
|
||||
},
|
||||
status_code=404,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Fetched chunk context for {doc_type}_{doc_id}: "
|
||||
f"chunk_len={len(chunk_context.chunk_text)}, "
|
||||
f"before_len={len(chunk_context.before_context)}, "
|
||||
f"after_len={len(chunk_context.after_context)}"
|
||||
)
|
||||
|
||||
# For PDF files, also fetch the highlighted page image from Qdrant
|
||||
highlighted_page_image = None
|
||||
page_number = None
|
||||
if doc_type == "file":
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
settings = get_settings()
|
||||
qdrant_client = await get_qdrant_client()
|
||||
username = request.user.display_name
|
||||
|
||||
# Query for this specific chunk's highlighted image
|
||||
points_response = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
get_placeholder_filter(),
|
||||
FieldCondition(
|
||||
key="doc_id", match=MatchValue(value=doc_id_int)
|
||||
),
|
||||
FieldCondition(
|
||||
key="user_id", match=MatchValue(value=username)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_vectors=False,
|
||||
with_payload=["highlighted_page_image", "page_number"],
|
||||
)
|
||||
|
||||
points = points_response[0]
|
||||
if points and points[0].payload:
|
||||
highlighted_page_image = points[0].payload.get(
|
||||
"highlighted_page_image"
|
||||
)
|
||||
page_number = points[0].payload.get("page_number")
|
||||
if highlighted_page_image:
|
||||
logger.info(
|
||||
f"Found highlighted image for chunk: "
|
||||
f"page={page_number}, image_size={len(highlighted_page_image)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch highlighted image: {e}")
|
||||
|
||||
# Return response compatible with frontend expectations
|
||||
response_data: dict = {
|
||||
"success": True,
|
||||
"chunk_text": chunk_context.chunk_text,
|
||||
"before_context": chunk_context.before_context,
|
||||
"after_context": chunk_context.after_context,
|
||||
"has_more_before": chunk_context.has_before_truncation,
|
||||
"has_more_after": chunk_context.has_after_truncation,
|
||||
}
|
||||
|
||||
# Add image data if available
|
||||
if highlighted_page_image:
|
||||
response_data["highlighted_page_image"] = highlighted_page_image
|
||||
response_data["page_number"] = page_number
|
||||
|
||||
return JSONResponse(response_data)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Invalid parameter format: {e}")
|
||||
return JSONResponse(
|
||||
{"success": False, "error": f"Invalid parameter format: {e}"},
|
||||
status_code=400,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk context error: {e}", exc_info=True)
|
||||
return JSONResponse(
|
||||
{"success": False, "error": str(e)},
|
||||
status_code=500,
|
||||
)
|
||||
|
||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
||||
raise RuntimeError("BasicAuth credentials not configured")
|
||||
|
||||
assert nextcloud_host is not None # Type narrowing for type checker
|
||||
assert username is not None and password is not None # Type narrowing
|
||||
return httpx.AsyncClient(
|
||||
base_url=nextcloud_host,
|
||||
auth=(username, password),
|
||||
|
||||
@@ -29,9 +29,9 @@ from .app import get_app
|
||||
@click.option(
|
||||
"--transport",
|
||||
"-t",
|
||||
default="sse",
|
||||
default="streamable-http",
|
||||
show_default=True,
|
||||
type=click.Choice(["sse", "streamable-http", "http"]),
|
||||
type=click.Choice(["streamable-http", "http"]),
|
||||
help="MCP transport protocol",
|
||||
)
|
||||
@click.option(
|
||||
@@ -253,5 +253,195 @@ def run(
|
||||
)
|
||||
|
||||
|
||||
@click.group()
|
||||
def db():
|
||||
"""Database migration management commands."""
|
||||
pass
|
||||
|
||||
|
||||
@db.command()
|
||||
@click.option(
|
||||
"--database-path",
|
||||
"-d",
|
||||
envvar="TOKEN_STORAGE_DB",
|
||||
default="/app/data/tokens.db",
|
||||
show_default=True,
|
||||
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
|
||||
)
|
||||
@click.option(
|
||||
"--revision",
|
||||
"-r",
|
||||
default="head",
|
||||
show_default=True,
|
||||
help="Target revision (default: head for latest)",
|
||||
)
|
||||
def upgrade(database_path: str, revision: str):
|
||||
"""Upgrade database to a specific revision.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# Upgrade to latest version
|
||||
$ nextcloud-mcp-server db upgrade
|
||||
|
||||
# Upgrade to specific revision
|
||||
$ nextcloud-mcp-server db upgrade --revision 001
|
||||
|
||||
# Use custom database path
|
||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import upgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Upgrading database to revision: {revision}")
|
||||
upgrade_database(database_path, revision)
|
||||
click.echo(click.style("✓ Database upgraded successfully", fg="green"))
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"✗ Upgrade failed: {e}", fg="red"), err=True)
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
@db.command()
|
||||
@click.option(
|
||||
"--database-path",
|
||||
"-d",
|
||||
envvar="TOKEN_STORAGE_DB",
|
||||
default="/app/data/tokens.db",
|
||||
show_default=True,
|
||||
help="Path to token storage database",
|
||||
)
|
||||
@click.option(
|
||||
"--revision",
|
||||
"-r",
|
||||
default="-1",
|
||||
show_default=True,
|
||||
help="Target revision (default: -1 for previous version)",
|
||||
)
|
||||
@click.confirmation_option(
|
||||
prompt="Are you sure you want to downgrade the database? This may result in data loss."
|
||||
)
|
||||
def downgrade(database_path: str, revision: str):
|
||||
"""Downgrade database to a specific revision.
|
||||
|
||||
WARNING: This may result in data loss! Use with caution.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
# Downgrade by one version
|
||||
$ nextcloud-mcp-server db downgrade
|
||||
|
||||
# Downgrade to specific revision
|
||||
$ nextcloud-mcp-server db downgrade --revision 001
|
||||
|
||||
# Downgrade to base (empty database)
|
||||
$ nextcloud-mcp-server db downgrade --revision base
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import downgrade_database
|
||||
|
||||
try:
|
||||
click.echo(f"Downgrading database to revision: {revision}")
|
||||
downgrade_database(database_path, revision)
|
||||
click.echo(click.style("✓ Database downgraded successfully", fg="green"))
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"✗ Downgrade failed: {e}", fg="red"), err=True)
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
@db.command()
|
||||
@click.option(
|
||||
"--database-path",
|
||||
"-d",
|
||||
envvar="TOKEN_STORAGE_DB",
|
||||
default="/app/data/tokens.db",
|
||||
show_default=True,
|
||||
help="Path to token storage database",
|
||||
)
|
||||
def current(database_path: str):
|
||||
"""Show current database revision.
|
||||
|
||||
\b
|
||||
Example:
|
||||
$ nextcloud-mcp-server db current
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import get_current_revision
|
||||
|
||||
try:
|
||||
revision = get_current_revision(database_path)
|
||||
if revision:
|
||||
click.echo(f"Current revision: {click.style(revision, fg='cyan')}")
|
||||
else:
|
||||
click.echo(
|
||||
click.style(
|
||||
"Database is not versioned (no alembic_version table)", fg="yellow"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
click.style(f"✗ Failed to get current revision: {e}", fg="red"), err=True
|
||||
)
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
@db.command()
|
||||
@click.option(
|
||||
"--database-path",
|
||||
"-d",
|
||||
envvar="TOKEN_STORAGE_DB",
|
||||
default="/app/data/tokens.db",
|
||||
show_default=True,
|
||||
help="Path to token storage database",
|
||||
)
|
||||
def history(database_path: str):
|
||||
"""Show migration history.
|
||||
|
||||
\b
|
||||
Example:
|
||||
$ nextcloud-mcp-server db history
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import show_migration_history
|
||||
|
||||
try:
|
||||
click.echo("Migration history:")
|
||||
show_migration_history(database_path)
|
||||
except Exception as e:
|
||||
click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True)
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
@db.command()
|
||||
@click.argument("message")
|
||||
def migrate(message: str):
|
||||
"""Create a new migration script (developers only).
|
||||
|
||||
The MESSAGE argument describes the changes in this migration.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
$ nextcloud-mcp-server db migrate "add user preferences table"
|
||||
$ nextcloud-mcp-server db migrate "add index on refresh_tokens.user_id"
|
||||
|
||||
Note: You must manually edit the generated migration file to add SQL statements.
|
||||
"""
|
||||
from nextcloud_mcp_server.migrations import create_migration
|
||||
|
||||
try:
|
||||
click.echo(f"Creating new migration: {message}")
|
||||
create_migration(message)
|
||||
click.echo(click.style("✓ Migration created successfully", fg="green"))
|
||||
click.echo(
|
||||
"Edit the migration file in alembic/versions/ to add upgrade/downgrade SQL."
|
||||
)
|
||||
except Exception as e:
|
||||
click.echo(
|
||||
click.style(f"✗ Failed to create migration: {e}", fg="red"), err=True
|
||||
)
|
||||
raise click.ClickException(str(e))
|
||||
|
||||
|
||||
# Create CLI group with subcommands
|
||||
cli = click.Group()
|
||||
cli.add_command(run)
|
||||
cli.add_command(db)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
cli()
|
||||
|
||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
||||
from .cookbook import CookbookClient
|
||||
from .deck import DeckClient
|
||||
from .groups import GroupsClient
|
||||
from .news import NewsClient
|
||||
from .notes import NotesClient
|
||||
from .sharing import SharingClient
|
||||
from .tables import TablesClient
|
||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
||||
self.contacts = ContactsClient(self._client, username)
|
||||
self.cookbook = CookbookClient(self._client, username)
|
||||
self.deck = DeckClient(self._client, username)
|
||||
self.news = NewsClient(self._client, username)
|
||||
self.users = UsersClient(self._client, username)
|
||||
self.groups = GroupsClient(self._client, username)
|
||||
self.sharing = SharingClient(self._client, username)
|
||||
@@ -130,10 +132,75 @@ class NextcloudClient:
|
||||
all_notes = self.notes.get_all_notes()
|
||||
return await self._notes_search.search_notes(all_notes, query)
|
||||
|
||||
async def find_files_by_tag(
|
||||
self, tag_name: str, mime_type_filter: str | None = None
|
||||
) -> list[dict]:
|
||||
"""Find files by system tag name, optionally filtered by MIME type.
|
||||
|
||||
This method coordinates tag lookup and file retrieval via WebDAV:
|
||||
1. Look up the tag ID by name
|
||||
2. Get all files with that tag (via REPORT with full metadata)
|
||||
3. Optionally filter by MIME type
|
||||
|
||||
Args:
|
||||
tag_name: Name of the system tag to search for (e.g., "vector-index")
|
||||
mime_type_filter: Optional MIME type filter (e.g., "application/pdf")
|
||||
|
||||
Returns:
|
||||
List of file dictionaries with WebDAV properties (path, size, content_type, etc.)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If tag lookup or file query fails
|
||||
|
||||
Examples:
|
||||
# Find all files with "vector-index" tag
|
||||
files = await nc_client.find_files_by_tag("vector-index")
|
||||
|
||||
# Find only PDFs with the tag
|
||||
pdfs = await nc_client.find_files_by_tag("vector-index", "application/pdf")
|
||||
"""
|
||||
# Look up tag by name using WebDAV
|
||||
tag = await self.webdav.get_tag_by_name(tag_name)
|
||||
if not tag:
|
||||
logger.debug(f"Tag '{tag_name}' not found, returning empty list")
|
||||
return []
|
||||
|
||||
# Get files with this tag (returns full file info from REPORT)
|
||||
files = await self.webdav.get_files_by_tag(tag["id"])
|
||||
if not files:
|
||||
logger.debug(f"No files found with tag '{tag_name}'")
|
||||
return []
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag '{tag_name}'")
|
||||
|
||||
# Apply MIME type filter if specified
|
||||
if mime_type_filter:
|
||||
filtered_files = [
|
||||
f
|
||||
for f in files
|
||||
if f.get("content_type", "").startswith(mime_type_filter)
|
||||
]
|
||||
logger.info(
|
||||
f"Returning {len(filtered_files)} files with tag '{tag_name}' (filtered by {mime_type_filter})"
|
||||
)
|
||||
return filtered_files
|
||||
|
||||
logger.info(f"Returning {len(files)} files with tag '{tag_name}'")
|
||||
return files
|
||||
|
||||
def _get_webdav_base_path(self) -> str:
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit - closes all clients."""
|
||||
await self.close()
|
||||
return False # Don't suppress exceptions
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP client and CalDAV client."""
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Client for Nextcloud News app operations."""
|
||||
|
||||
import logging
|
||||
from enum import IntEnum
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseNextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NewsItemType(IntEnum):
|
||||
"""Type constants for News API item queries."""
|
||||
|
||||
FEED = 0 # Single feed
|
||||
FOLDER = 1 # Folder and its feeds
|
||||
STARRED = 2 # All starred items
|
||||
ALL = 3 # All items
|
||||
|
||||
|
||||
class NewsClient(BaseNextcloudClient):
|
||||
"""Client for Nextcloud News app operations."""
|
||||
|
||||
app_name = "news"
|
||||
API_BASE = "/apps/news/api/v1-3"
|
||||
|
||||
# --- Folders ---
|
||||
|
||||
async def get_folders(self) -> list[dict[str, Any]]:
|
||||
"""Get all folders."""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/folders")
|
||||
return response.json().get("folders", [])
|
||||
|
||||
async def create_folder(self, name: str) -> dict[str, Any]:
|
||||
"""Create a new folder.
|
||||
|
||||
Args:
|
||||
name: Folder name
|
||||
|
||||
Returns:
|
||||
Created folder data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 409 if folder name already exists,
|
||||
422 if name is empty
|
||||
"""
|
||||
response = await self._make_request(
|
||||
"POST", f"{self.API_BASE}/folders", json={"name": name}
|
||||
)
|
||||
folders = response.json().get("folders", [])
|
||||
return folders[0] if folders else {}
|
||||
|
||||
async def rename_folder(self, folder_id: int, name: str) -> None:
|
||||
"""Rename a folder.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
name: New folder name
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found, 409 if name exists
|
||||
"""
|
||||
await self._make_request(
|
||||
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
|
||||
)
|
||||
|
||||
async def delete_folder(self, folder_id: int) -> None:
|
||||
"""Delete a folder and all its feeds/items.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found
|
||||
"""
|
||||
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
|
||||
|
||||
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
|
||||
"""Mark all items in a folder as read.
|
||||
|
||||
Args:
|
||||
folder_id: Folder ID
|
||||
newest_item_id: ID of newest item to mark read (prevents marking
|
||||
items user hasn't seen yet)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if folder not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/folders/{folder_id}/read",
|
||||
json={"newestItemId": newest_item_id},
|
||||
)
|
||||
|
||||
# --- Feeds ---
|
||||
|
||||
async def get_feeds(self) -> dict[str, Any]:
|
||||
"""Get all feeds with metadata.
|
||||
|
||||
Returns:
|
||||
Dict with keys:
|
||||
- feeds: List of feed objects
|
||||
- starredCount: Number of starred items
|
||||
- newestItemId: ID of newest item (omitted if no items)
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
|
||||
return response.json()
|
||||
|
||||
async def create_feed(
|
||||
self, url: str, folder_id: int | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Subscribe to a new feed.
|
||||
|
||||
Args:
|
||||
url: Feed URL
|
||||
folder_id: Optional folder ID (None for root)
|
||||
|
||||
Returns:
|
||||
Created feed data
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
|
||||
"""
|
||||
body: dict[str, Any] = {"url": url}
|
||||
if folder_id is not None:
|
||||
body["folderId"] = folder_id
|
||||
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
|
||||
data = response.json()
|
||||
feeds = data.get("feeds", [])
|
||||
return feeds[0] if feeds else {}
|
||||
|
||||
async def delete_feed(self, feed_id: int) -> None:
|
||||
"""Unsubscribe from a feed (deletes all items).
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
|
||||
|
||||
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
|
||||
"""Move a feed to a different folder.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
folder_id: Target folder ID (None for root)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/move",
|
||||
json={"folderId": folder_id},
|
||||
)
|
||||
|
||||
async def rename_feed(self, feed_id: int, title: str) -> None:
|
||||
"""Rename a feed.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
title: New feed title
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/rename",
|
||||
json={"feedTitle": title},
|
||||
)
|
||||
|
||||
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
|
||||
"""Mark all items in a feed as read.
|
||||
|
||||
Args:
|
||||
feed_id: Feed ID
|
||||
newest_item_id: ID of newest item to mark read
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if feed not found
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/feeds/{feed_id}/read",
|
||||
json={"newestItemId": newest_item_id},
|
||||
)
|
||||
|
||||
# --- Items ---
|
||||
|
||||
async def get_items(
|
||||
self,
|
||||
batch_size: int = 50,
|
||||
offset: int = 0,
|
||||
type_: int = NewsItemType.ALL,
|
||||
id_: int = 0,
|
||||
get_read: bool = True,
|
||||
oldest_first: bool = False,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get items (articles) with filtering.
|
||||
|
||||
Args:
|
||||
batch_size: Number of items to return (-1 for all)
|
||||
offset: Item ID to start after (for pagination)
|
||||
type_: Item type filter (NewsItemType)
|
||||
id_: Feed/folder ID (ignored for STARRED/ALL types)
|
||||
get_read: Include read items
|
||||
oldest_first: Sort oldest first instead of newest
|
||||
|
||||
Returns:
|
||||
List of item objects
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"batchSize": batch_size,
|
||||
"offset": offset,
|
||||
"type": type_,
|
||||
"id": id_,
|
||||
"getRead": str(get_read).lower(),
|
||||
"oldestFirst": str(oldest_first).lower(),
|
||||
}
|
||||
response = await self._make_request(
|
||||
"GET", f"{self.API_BASE}/items", params=params
|
||||
)
|
||||
return response.json().get("items", [])
|
||||
|
||||
async def get_item(self, item_id: int) -> dict[str, Any]:
|
||||
"""Get a specific item by ID.
|
||||
|
||||
Note: The News API doesn't have a direct single-item endpoint,
|
||||
so we fetch all items and filter. For efficiency, consider
|
||||
caching or using get_items with specific feed if known.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Returns:
|
||||
Item data
|
||||
|
||||
Raises:
|
||||
ValueError: If item not found
|
||||
"""
|
||||
# Fetch all items and find the one we need
|
||||
# This is inefficient but the API doesn't provide a direct endpoint
|
||||
items = await self.get_items(batch_size=-1, get_read=True)
|
||||
for item in items:
|
||||
if item.get("id") == item_id:
|
||||
return item
|
||||
raise ValueError(f"Item {item_id} not found")
|
||||
|
||||
async def get_updated_items(
|
||||
self,
|
||||
last_modified: int,
|
||||
type_: int = NewsItemType.ALL,
|
||||
id_: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Get items modified since a timestamp (for delta sync).
|
||||
|
||||
Args:
|
||||
last_modified: Unix timestamp (seconds or microseconds)
|
||||
type_: Item type filter
|
||||
id_: Feed/folder ID
|
||||
|
||||
Returns:
|
||||
List of modified items (includes deleted items)
|
||||
"""
|
||||
params: dict[str, Any] = {
|
||||
"lastModified": last_modified,
|
||||
"type": type_,
|
||||
"id": id_,
|
||||
}
|
||||
response = await self._make_request(
|
||||
"GET", f"{self.API_BASE}/items/updated", params=params
|
||||
)
|
||||
return response.json().get("items", [])
|
||||
|
||||
async def mark_item_read(self, item_id: int) -> None:
|
||||
"""Mark a single item as read.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
|
||||
|
||||
async def mark_item_unread(self, item_id: int) -> None:
|
||||
"""Mark a single item as unread.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
|
||||
|
||||
async def star_item(self, item_id: int) -> None:
|
||||
"""Star (favorite) a single item.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
|
||||
|
||||
async def unstar_item(self, item_id: int) -> None:
|
||||
"""Unstar a single item.
|
||||
|
||||
Args:
|
||||
item_id: Item ID
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: 404 if item not found
|
||||
"""
|
||||
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
|
||||
|
||||
async def mark_items_read(self, item_ids: list[int]) -> None:
|
||||
"""Mark multiple items as read.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
|
||||
)
|
||||
|
||||
async def mark_items_unread(self, item_ids: list[int]) -> None:
|
||||
"""Mark multiple items as unread.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/items/unread/multiple",
|
||||
json={"itemIds": item_ids},
|
||||
)
|
||||
|
||||
async def star_items(self, item_ids: list[int]) -> None:
|
||||
"""Star multiple items.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
|
||||
)
|
||||
|
||||
async def unstar_items(self, item_ids: list[int]) -> None:
|
||||
"""Unstar multiple items.
|
||||
|
||||
Args:
|
||||
item_ids: List of item IDs
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST",
|
||||
f"{self.API_BASE}/items/unstar/multiple",
|
||||
json={"itemIds": item_ids},
|
||||
)
|
||||
|
||||
async def mark_all_read(self, newest_item_id: int) -> None:
|
||||
"""Mark all items as read.
|
||||
|
||||
Args:
|
||||
newest_item_id: ID of newest item to mark read
|
||||
"""
|
||||
await self._make_request(
|
||||
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
|
||||
)
|
||||
|
||||
# --- Status ---
|
||||
|
||||
async def get_status(self) -> dict[str, Any]:
|
||||
"""Get News app status and configuration.
|
||||
|
||||
Returns:
|
||||
Dict with version and warnings
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/status")
|
||||
return response.json()
|
||||
|
||||
async def get_version(self) -> str:
|
||||
"""Get News app version.
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "25.0.0")
|
||||
"""
|
||||
response = await self._make_request("GET", f"{self.API_BASE}/version")
|
||||
return response.json().get("version", "")
|
||||
@@ -821,6 +821,20 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
item["file_id"] = int(value) if value else None
|
||||
elif tag == "favorite":
|
||||
item["is_favorite"] = value == "1"
|
||||
elif tag == "tags":
|
||||
# Tags can be comma-separated or have multiple child elements
|
||||
if value:
|
||||
# Handle comma-separated tags
|
||||
item["tags"] = [
|
||||
t.strip() for t in value.split(",") if t.strip()
|
||||
]
|
||||
else:
|
||||
# Check for child tag elements (alternative format)
|
||||
tag_elements = child.findall(".//{http://owncloud.org/ns}tag")
|
||||
if tag_elements:
|
||||
item["tags"] = [t.text for t in tag_elements if t.text]
|
||||
else:
|
||||
item["tags"] = []
|
||||
elif tag == "permissions":
|
||||
item["permissions"] = value
|
||||
elif tag == "size":
|
||||
@@ -948,3 +962,576 @@ class WebDAVClient(BaseNextcloudClient):
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def find_by_tag(
|
||||
self, tag_name: str, scope: str = "", limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Find files by tag name.
|
||||
|
||||
DEPRECATED: Use NextcloudClient.find_files_by_tag() instead, which uses
|
||||
the proper OCS Tags API rather than WebDAV SEARCH.
|
||||
|
||||
Args:
|
||||
tag_name: Tag to filter by (e.g., "vector-index")
|
||||
scope: Directory path to search in (empty string for user root)
|
||||
limit: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of files/directories with the specified tag
|
||||
|
||||
Examples:
|
||||
# Find all files tagged with "vector-index"
|
||||
results = await find_by_tag("vector-index")
|
||||
|
||||
# Find tagged files in a specific folder
|
||||
results = await find_by_tag("vector-index", scope="Documents")
|
||||
"""
|
||||
# Use LIKE for tag matching since tags can be comma-separated
|
||||
where_conditions = f"""
|
||||
<d:like>
|
||||
<d:prop>
|
||||
<oc:tags/>
|
||||
</d:prop>
|
||||
<d:literal>%{tag_name}%</d:literal>
|
||||
</d:like>
|
||||
"""
|
||||
|
||||
# Request tag property along with standard properties
|
||||
properties = [
|
||||
"displayname",
|
||||
"getcontentlength",
|
||||
"getcontenttype",
|
||||
"getlastmodified",
|
||||
"resourcetype",
|
||||
"getetag",
|
||||
"fileid",
|
||||
"tags",
|
||||
]
|
||||
|
||||
return await self.search_files(
|
||||
scope=scope,
|
||||
where_conditions=where_conditions,
|
||||
properties=properties,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
async def _get_file_info_by_id(self, file_id: int) -> Dict[str, Any]:
|
||||
"""Get file information by Nextcloud file ID using WebDAV.
|
||||
|
||||
Args:
|
||||
file_id: Nextcloud internal file ID
|
||||
|
||||
Returns:
|
||||
File information dictionary with path, size, content_type, etc.
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If file not found or request fails
|
||||
"""
|
||||
# Nextcloud allows accessing files by ID via special meta endpoint
|
||||
meta_path = f"/remote.php/dav/meta/{file_id}/"
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:resourcetype/>
|
||||
<d:getetag/>
|
||||
<oc:fileid/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
headers = {"Depth": "0", "Content-Type": "text/xml", "OCS-APIRequest": "true"}
|
||||
|
||||
response = await self._make_request(
|
||||
"PROPFIND", meta_path, content=propfind_body, headers=headers
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse the XML response
|
||||
root = ET.fromstring(response.content)
|
||||
responses = root.findall(".//{DAV:}response")
|
||||
|
||||
if not responses:
|
||||
raise RuntimeError(f"File ID {file_id} not found")
|
||||
|
||||
response_elem = responses[0]
|
||||
href = response_elem.find(".//{DAV:}href")
|
||||
if href is None:
|
||||
raise RuntimeError(f"No href in response for file ID {file_id}")
|
||||
|
||||
propstat = response_elem.find(".//{DAV:}propstat")
|
||||
if propstat is None:
|
||||
raise RuntimeError(f"No propstat for file ID {file_id}")
|
||||
|
||||
prop = propstat.find(".//{DAV:}prop")
|
||||
if prop is None:
|
||||
raise RuntimeError(f"No prop for file ID {file_id}")
|
||||
|
||||
# Extract file path from displayname or construct from file ID
|
||||
displayname_elem = prop.find(".//{DAV:}displayname")
|
||||
name = (
|
||||
displayname_elem.text if displayname_elem is not None else f"file_{file_id}"
|
||||
)
|
||||
|
||||
# Get file properties
|
||||
size_elem = prop.find(".//{DAV:}getcontentlength")
|
||||
size = int(size_elem.text) if size_elem is not None and size_elem.text else 0
|
||||
|
||||
content_type_elem = prop.find(".//{DAV:}getcontenttype")
|
||||
content_type = content_type_elem.text if content_type_elem is not None else None
|
||||
|
||||
modified_elem = prop.find(".//{DAV:}getlastmodified")
|
||||
modified = modified_elem.text if modified_elem is not None else None
|
||||
|
||||
etag_elem = prop.find(".//{DAV:}getetag")
|
||||
etag = (
|
||||
etag_elem.text.strip('"')
|
||||
if etag_elem is not None and etag_elem.text
|
||||
else None
|
||||
)
|
||||
|
||||
# Check if it's a directory
|
||||
resourcetype = prop.find(".//{DAV:}resourcetype")
|
||||
is_directory = (
|
||||
resourcetype is not None
|
||||
and resourcetype.find(".//{DAV:}collection") is not None
|
||||
)
|
||||
|
||||
# Try to get actual file path - meta endpoint doesn't give us the real path
|
||||
# so we'll construct a reasonable path from the name
|
||||
# The calling code in NextcloudClient will have the context to determine the actual path
|
||||
file_info = {
|
||||
"name": name,
|
||||
"path": f"/{name}", # Placeholder - caller should use WebDAV to get real path if needed
|
||||
"size": size,
|
||||
"content_type": content_type,
|
||||
"last_modified": modified,
|
||||
"etag": etag,
|
||||
"is_directory": is_directory,
|
||||
"file_id": file_id,
|
||||
}
|
||||
|
||||
logger.debug(f"Retrieved file info for ID {file_id}: {name}")
|
||||
return file_info
|
||||
|
||||
async def get_tag_by_name(self, tag_name: str) -> dict[str, Any] | None:
|
||||
"""Get a system tag by its name via WebDAV.
|
||||
|
||||
Args:
|
||||
tag_name: Name of the tag to find (case-sensitive)
|
||||
|
||||
Returns:
|
||||
Tag dictionary if found, None otherwise
|
||||
"""
|
||||
# Use WebDAV PROPFIND to list all systemtags
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:id/>
|
||||
<oc:display-name/>
|
||||
<oc:user-visible/>
|
||||
<oc:user-assignable/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
"/remote.php/dav/systemtags/",
|
||||
headers={"Depth": "1"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
for response_elem in root.findall("d:response", ns):
|
||||
href = response_elem.find("d:href", ns)
|
||||
if href is None or href.text == "/remote.php/dav/systemtags/":
|
||||
# Skip the collection itself
|
||||
continue
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Extract tag properties
|
||||
tag_id_elem = prop.find("oc:id", ns)
|
||||
display_name_elem = prop.find("oc:display-name", ns)
|
||||
user_visible_elem = prop.find("oc:user-visible", ns)
|
||||
user_assignable_elem = prop.find("oc:user-assignable", ns)
|
||||
|
||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||
tag_info = {
|
||||
"id": int(tag_id_elem.text)
|
||||
if tag_id_elem is not None and tag_id_elem.text is not None
|
||||
else None,
|
||||
"name": display_name_elem.text,
|
||||
"userVisible": user_visible_elem.text.lower() == "true"
|
||||
if user_visible_elem is not None
|
||||
and user_visible_elem.text is not None
|
||||
else True,
|
||||
"userAssignable": user_assignable_elem.text.lower() == "true"
|
||||
if user_assignable_elem is not None
|
||||
and user_assignable_elem.text is not None
|
||||
else True,
|
||||
}
|
||||
logger.debug(f"Found tag '{tag_name}' with ID {tag_info['id']}")
|
||||
return tag_info
|
||||
|
||||
logger.debug(f"Tag '{tag_name}' not found")
|
||||
return None
|
||||
|
||||
async def get_files_by_tag(self, tag_id: int) -> list[dict[str, Any]]:
|
||||
"""Get all files tagged with a specific system tag via WebDAV REPORT.
|
||||
|
||||
Args:
|
||||
tag_id: Numeric ID of the tag
|
||||
|
||||
Returns:
|
||||
List of file info dictionaries with path, size, content_type, etc.
|
||||
"""
|
||||
# Use WebDAV REPORT method with systemtag filter, requesting all properties
|
||||
report_body = f"""<?xml version="1.0"?>
|
||||
<oc:filter-files xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns" xmlns:nc="http://nextcloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
</d:prop>
|
||||
<oc:filter-rules>
|
||||
<oc:systemtag>{tag_id}</oc:systemtag>
|
||||
</oc:filter-rules>
|
||||
</oc:filter-files>"""
|
||||
|
||||
response = await self._client.request(
|
||||
"REPORT",
|
||||
f"{self._get_webdav_base_path()}/",
|
||||
content=report_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
files = []
|
||||
for response_elem in root.findall("d:response", ns):
|
||||
# Extract href (file path)
|
||||
href_elem = response_elem.find("d:href", ns)
|
||||
if href_elem is None or not href_elem.text:
|
||||
continue
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
continue
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
continue
|
||||
|
||||
# Extract all properties
|
||||
fileid_elem = prop.find("oc:fileid", ns)
|
||||
displayname_elem = prop.find("d:displayname", ns)
|
||||
contentlength_elem = prop.find("d:getcontentlength", ns)
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
|
||||
if fileid_elem is None or not fileid_elem.text:
|
||||
continue
|
||||
|
||||
# Decode href path and extract the file path
|
||||
from urllib.parse import unquote
|
||||
|
||||
href_path = unquote(href_elem.text)
|
||||
# Remove WebDAV prefix to get user-relative path
|
||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||
file_path = href_path.replace(webdav_prefix, "/")
|
||||
|
||||
# Parse last modified timestamp
|
||||
last_modified_timestamp = None
|
||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
try:
|
||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||
last_modified_timestamp = int(dt.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
file_info = {
|
||||
"id": int(fileid_elem.text),
|
||||
"path": file_path,
|
||||
"name": displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else file_path.split("/")[-1],
|
||||
"size": int(contentlength_elem.text)
|
||||
if contentlength_elem is not None and contentlength_elem.text
|
||||
else 0,
|
||||
"content_type": contenttype_elem.text
|
||||
if contenttype_elem is not None
|
||||
else "",
|
||||
"last_modified": lastmodified_elem.text
|
||||
if lastmodified_elem is not None
|
||||
else None,
|
||||
"last_modified_timestamp": last_modified_timestamp,
|
||||
"etag": etag_elem.text if etag_elem is not None else None,
|
||||
}
|
||||
files.append(file_info)
|
||||
|
||||
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
|
||||
return files
|
||||
|
||||
async def get_file_info(self, path: str) -> dict[str, Any] | None:
|
||||
"""Get file info including file ID via WebDAV PROPFIND.
|
||||
|
||||
Args:
|
||||
path: Path to the file (relative to user's files directory)
|
||||
|
||||
Returns:
|
||||
File info dictionary with id, name, size, content_type, etc.
|
||||
Returns None if file not found.
|
||||
"""
|
||||
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
|
||||
|
||||
propfind_body = """<?xml version="1.0"?>
|
||||
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
|
||||
<d:prop>
|
||||
<oc:fileid/>
|
||||
<d:displayname/>
|
||||
<d:getcontentlength/>
|
||||
<d:getcontenttype/>
|
||||
<d:getlastmodified/>
|
||||
<d:getetag/>
|
||||
<d:resourcetype/>
|
||||
</d:prop>
|
||||
</d:propfind>"""
|
||||
|
||||
try:
|
||||
response = await self._client.request(
|
||||
"PROPFIND",
|
||||
webdav_path,
|
||||
headers={"Depth": "0"},
|
||||
content=propfind_body,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 404:
|
||||
logger.debug(f"File not found: {path}")
|
||||
return None
|
||||
raise
|
||||
|
||||
# Parse XML response
|
||||
root = ET.fromstring(response.content)
|
||||
ns = {
|
||||
"d": "DAV:",
|
||||
"oc": "http://owncloud.org/ns",
|
||||
}
|
||||
|
||||
response_elem = root.find("d:response", ns)
|
||||
if response_elem is None:
|
||||
return None
|
||||
|
||||
propstat = response_elem.find("d:propstat", ns)
|
||||
if propstat is None:
|
||||
return None
|
||||
|
||||
prop = propstat.find("d:prop", ns)
|
||||
if prop is None:
|
||||
return None
|
||||
|
||||
# Extract properties
|
||||
fileid_elem = prop.find("oc:fileid", ns)
|
||||
displayname_elem = prop.find("d:displayname", ns)
|
||||
contentlength_elem = prop.find("d:getcontentlength", ns)
|
||||
contenttype_elem = prop.find("d:getcontenttype", ns)
|
||||
lastmodified_elem = prop.find("d:getlastmodified", ns)
|
||||
etag_elem = prop.find("d:getetag", ns)
|
||||
resourcetype_elem = prop.find("d:resourcetype", ns)
|
||||
|
||||
is_directory = (
|
||||
resourcetype_elem is not None
|
||||
and resourcetype_elem.find("d:collection", ns) is not None
|
||||
)
|
||||
|
||||
file_info = {
|
||||
"id": int(fileid_elem.text)
|
||||
if fileid_elem is not None and fileid_elem.text is not None
|
||||
else None,
|
||||
"path": path,
|
||||
"name": displayname_elem.text
|
||||
if displayname_elem is not None
|
||||
else path.split("/")[-1],
|
||||
"size": int(contentlength_elem.text)
|
||||
if contentlength_elem is not None and contentlength_elem.text
|
||||
else 0,
|
||||
"content_type": contenttype_elem.text
|
||||
if contenttype_elem is not None
|
||||
else "",
|
||||
"last_modified": lastmodified_elem.text
|
||||
if lastmodified_elem is not None
|
||||
else None,
|
||||
"etag": etag_elem.text.strip('"')
|
||||
if etag_elem is not None and etag_elem.text
|
||||
else None,
|
||||
"is_directory": is_directory,
|
||||
}
|
||||
|
||||
logger.debug(f"Got file info for '{path}': id={file_info['id']}")
|
||||
return file_info
|
||||
|
||||
async def create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a system tag via WebDAV.
|
||||
|
||||
Args:
|
||||
name: Name of the tag to create
|
||||
user_visible: Whether the tag is visible to users
|
||||
user_assignable: Whether users can assign this tag
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag creation fails (409 if already exists)
|
||||
"""
|
||||
# Use WebDAV POST with JSON body to create tag
|
||||
response = await self._client.post(
|
||||
"/remote.php/dav/systemtags/",
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Extract tag ID from Content-Location header (e.g., /remote.php/dav/systemtags/42)
|
||||
content_location = response.headers.get("Content-Location", "")
|
||||
tag_id = None
|
||||
if content_location:
|
||||
# Extract the numeric ID from the path
|
||||
try:
|
||||
tag_id = int(content_location.rstrip("/").split("/")[-1])
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
tag_info = {
|
||||
"id": tag_id,
|
||||
"name": name,
|
||||
"userVisible": user_visible,
|
||||
"userAssignable": user_assignable,
|
||||
}
|
||||
|
||||
logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
|
||||
return tag_info
|
||||
|
||||
async def get_or_create_tag(
|
||||
self,
|
||||
name: str,
|
||||
user_visible: bool = True,
|
||||
user_assignable: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""Get a tag by name, creating it if it doesn't exist.
|
||||
|
||||
Args:
|
||||
name: Name of the tag
|
||||
user_visible: Whether the tag is visible to users (for creation)
|
||||
user_assignable: Whether users can assign this tag (for creation)
|
||||
|
||||
Returns:
|
||||
Tag dictionary with id, name, userVisible, userAssignable
|
||||
"""
|
||||
# First try to get existing tag
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
|
||||
return existing_tag
|
||||
|
||||
# Create new tag
|
||||
try:
|
||||
return await self.create_tag(name, user_visible, user_assignable)
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 409:
|
||||
# Tag was created between our check and creation, fetch it
|
||||
existing_tag = await self.get_tag_by_name(name)
|
||||
if existing_tag:
|
||||
return existing_tag
|
||||
raise
|
||||
|
||||
async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Assign a system tag to a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was assigned successfully (or already assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag assignment fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"PUT",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
headers={"Content-Length": "0"},
|
||||
content=b"",
|
||||
)
|
||||
|
||||
# 201 = Created (new assignment), 409 = Conflict (already assigned)
|
||||
if response.status_code in (201, 409):
|
||||
logger.info(f"Tagged file {file_id} with tag {tag_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
|
||||
"""Remove a system tag from a file.
|
||||
|
||||
Args:
|
||||
file_id: Numeric file ID
|
||||
tag_id: Numeric tag ID
|
||||
|
||||
Returns:
|
||||
True if tag was removed successfully (or wasn't assigned)
|
||||
|
||||
Raises:
|
||||
HTTPStatusError: If tag removal fails
|
||||
"""
|
||||
response = await self._client.request(
|
||||
"DELETE",
|
||||
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
|
||||
)
|
||||
|
||||
# 204 = No Content (removed), 404 = Not Found (wasn't assigned)
|
||||
if response.status_code in (204, 404):
|
||||
logger.info(f"Removed tag {tag_id} from file {file_id}")
|
||||
return True
|
||||
|
||||
response.raise_for_status()
|
||||
return True
|
||||
|
||||
@@ -2,8 +2,37 @@ import logging
|
||||
import logging.config
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
class DeploymentMode(Enum):
|
||||
"""Deployment mode for the MCP server.
|
||||
|
||||
SELF_HOSTED: Full features, environment-based configuration.
|
||||
Supports vector sync, semantic search, admin UI.
|
||||
|
||||
SMITHERY_STATELESS: Stateless mode for Smithery hosting.
|
||||
Session-based configuration, no persistent storage.
|
||||
Excludes semantic search, vector sync, admin UI.
|
||||
"""
|
||||
|
||||
SELF_HOSTED = "self_hosted"
|
||||
SMITHERY_STATELESS = "smithery"
|
||||
|
||||
|
||||
def get_deployment_mode() -> DeploymentMode:
|
||||
"""Detect deployment mode from environment.
|
||||
|
||||
Returns:
|
||||
DeploymentMode.SMITHERY_STATELESS if SMITHERY_DEPLOYMENT=true,
|
||||
otherwise DeploymentMode.SELF_HOSTED (default).
|
||||
"""
|
||||
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||
return DeploymentMode.SMITHERY_STATELESS
|
||||
return DeploymentMode.SELF_HOSTED
|
||||
|
||||
|
||||
LOGGING_CONFIG = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
@@ -102,6 +131,14 @@ def get_document_processor_config() -> dict[str, Any]:
|
||||
"lang": os.getenv("TESSERACT_LANG", "eng"),
|
||||
}
|
||||
|
||||
# PyMuPDF configuration (local PDF processing)
|
||||
if os.getenv("ENABLE_PYMUPDF", "true").lower() == "true": # Enabled by default
|
||||
config["processors"]["pymupdf"] = {
|
||||
"extract_images": os.getenv("PYMUPDF_EXTRACT_IMAGES", "true").lower()
|
||||
== "true",
|
||||
"image_dir": os.getenv("PYMUPDF_IMAGE_DIR"), # None = use temp directory
|
||||
}
|
||||
|
||||
# Custom processor (via HTTP API)
|
||||
if os.getenv("ENABLE_CUSTOM_PROCESSOR", "false").lower() == "true":
|
||||
custom_url = os.getenv("CUSTOM_PROCESSOR_URL")
|
||||
@@ -168,6 +205,7 @@ class Settings:
|
||||
vector_sync_scan_interval: int = 300 # seconds (5 minutes)
|
||||
vector_sync_processor_workers: int = 3
|
||||
vector_sync_queue_max_size: int = 10000
|
||||
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
|
||||
|
||||
# Qdrant settings (mutually exclusive modes)
|
||||
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
|
||||
@@ -180,9 +218,14 @@ class Settings:
|
||||
ollama_embedding_model: str = "nomic-embed-text"
|
||||
ollama_verify_ssl: bool = True
|
||||
|
||||
# OpenAI settings (for embeddings)
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_base_url: Optional[str] = None
|
||||
openai_embedding_model: str = "text-embedding-3-small"
|
||||
|
||||
# Document chunking settings (for vector embeddings)
|
||||
document_chunk_size: int = 512 # Words per chunk
|
||||
document_chunk_overlap: int = 50 # Overlapping words between chunks
|
||||
document_chunk_size: int = 2048 # Characters per chunk
|
||||
document_chunk_overlap: int = 200 # Overlapping characters between chunks
|
||||
|
||||
# Observability settings
|
||||
metrics_enabled: bool = True
|
||||
@@ -227,10 +270,10 @@ class Settings:
|
||||
f"Overlap should be 10-20% of chunk size for optimal results."
|
||||
)
|
||||
|
||||
if self.document_chunk_size < 100:
|
||||
if self.document_chunk_size < 512:
|
||||
logger.warning(
|
||||
f"DOCUMENT_CHUNK_SIZE is set to {self.document_chunk_size} words, which is quite small. "
|
||||
f"Smaller chunks may lose context. Consider using at least 256 words."
|
||||
f"DOCUMENT_CHUNK_SIZE is set to {self.document_chunk_size} characters, which is quite small. "
|
||||
f"Smaller chunks may lose context. Consider using at least 1024 characters."
|
||||
)
|
||||
|
||||
if self.document_chunk_overlap < 0:
|
||||
@@ -238,6 +281,29 @@ class Settings:
|
||||
f"DOCUMENT_CHUNK_OVERLAP ({self.document_chunk_overlap}) cannot be negative."
|
||||
)
|
||||
|
||||
def get_embedding_model_name(self) -> str:
|
||||
"""
|
||||
Get the active embedding model name based on provider priority.
|
||||
|
||||
Priority order (same as ProviderRegistry):
|
||||
1. OpenAI - if OPENAI_API_KEY is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback (returns "simple-384")
|
||||
|
||||
Returns:
|
||||
Active embedding model name
|
||||
"""
|
||||
# Check OpenAI first (higher priority than Ollama in registry)
|
||||
if self.openai_api_key:
|
||||
return self.openai_embedding_model
|
||||
|
||||
# Check Ollama
|
||||
if self.ollama_base_url:
|
||||
return self.ollama_embedding_model
|
||||
|
||||
# Fallback to simple provider indicator
|
||||
return "simple-384"
|
||||
|
||||
def get_collection_name(self) -> str:
|
||||
"""
|
||||
Get Qdrant collection name.
|
||||
@@ -253,8 +319,9 @@ class Settings:
|
||||
Format: {deployment-id}-{model-name}
|
||||
|
||||
Examples:
|
||||
- "my-deployment-nomic-embed-text" (OTEL_SERVICE_NAME set)
|
||||
- "mcp-container-all-minilm" (hostname fallback)
|
||||
- "my-deployment-nomic-embed-text" (Ollama)
|
||||
- "my-deployment-text-embedding-3-small" (OpenAI)
|
||||
- "mcp-container-openai-text-embedding-3-small" (hostname fallback)
|
||||
|
||||
Returns:
|
||||
Collection name string
|
||||
@@ -274,7 +341,7 @@ class Settings:
|
||||
|
||||
# Sanitize deployment ID and model name
|
||||
deployment_id = deployment_id.lower().replace(" ", "-").replace("_", "-")
|
||||
model_name = self.ollama_embedding_model.replace("/", "-").replace(":", "-")
|
||||
model_name = self.get_embedding_model_name().replace("/", "-").replace(":", "-")
|
||||
|
||||
return f"{deployment_id}-{model_name}"
|
||||
|
||||
@@ -325,6 +392,9 @@ def get_settings() -> Settings:
|
||||
vector_sync_queue_max_size=int(
|
||||
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
|
||||
),
|
||||
vector_sync_user_poll_interval=int(
|
||||
os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60")
|
||||
),
|
||||
# Qdrant settings
|
||||
qdrant_url=os.getenv("QDRANT_URL"),
|
||||
qdrant_location=os.getenv("QDRANT_LOCATION"),
|
||||
@@ -334,9 +404,15 @@ def get_settings() -> Settings:
|
||||
ollama_base_url=os.getenv("OLLAMA_BASE_URL"),
|
||||
ollama_embedding_model=os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text"),
|
||||
ollama_verify_ssl=os.getenv("OLLAMA_VERIFY_SSL", "true").lower() == "true",
|
||||
# OpenAI settings
|
||||
openai_api_key=os.getenv("OPENAI_API_KEY"),
|
||||
openai_base_url=os.getenv("OPENAI_BASE_URL"),
|
||||
openai_embedding_model=os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
),
|
||||
# Document chunking settings
|
||||
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "512")),
|
||||
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "50")),
|
||||
document_chunk_size=int(os.getenv("DOCUMENT_CHUNK_SIZE", "2048")),
|
||||
document_chunk_overlap=int(os.getenv("DOCUMENT_CHUNK_OVERLAP", "200")),
|
||||
# Observability settings
|
||||
metrics_enabled=os.getenv("METRICS_ENABLED", "true").lower() == "true",
|
||||
metrics_port=int(os.getenv("METRICS_PORT", "9090")),
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
import logging
|
||||
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.config import (
|
||||
DeploymentMode,
|
||||
get_deployment_mode,
|
||||
get_settings,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
ADR-005 compliant implementation supporting two modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
3. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
ADR-016 compliant implementation supporting three deployment modes:
|
||||
|
||||
1. Smithery stateless mode (SMITHERY_DEPLOYMENT=true):
|
||||
Create client from session configuration (nextcloud_url, username, app_password)
|
||||
No persistent state - client created per-request from Smithery session config.
|
||||
|
||||
2. BasicAuth mode: Returns shared client from lifespan context
|
||||
|
||||
3. OAuth mode:
|
||||
a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Token already contains both MCP and Nextcloud audiences - use directly
|
||||
b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchange MCP token for Nextcloud token via RFC 8693
|
||||
|
||||
SECURITY: Token passthrough has been REMOVED. All OAuth modes validate
|
||||
proper token audiences per MCP Security Best Practices specification.
|
||||
@@ -24,7 +40,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
by the MCP server via @require_scopes decorator, not by the IdP.
|
||||
|
||||
This function automatically detects the authentication mode by checking
|
||||
the type of the lifespan context.
|
||||
the deployment mode and type of the lifespan context.
|
||||
|
||||
Args:
|
||||
ctx: MCP request context
|
||||
@@ -34,6 +50,7 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
|
||||
Raises:
|
||||
AttributeError: If context doesn't contain expected data
|
||||
ValueError: If Smithery mode but session config is missing required fields
|
||||
|
||||
Example:
|
||||
```python
|
||||
@@ -43,6 +60,12 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
return await client.capabilities()
|
||||
```
|
||||
"""
|
||||
deployment_mode = get_deployment_mode()
|
||||
|
||||
# ADR-016: Smithery stateless mode - create client from session config
|
||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||
return _get_client_from_session_config(ctx)
|
||||
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
@@ -75,3 +98,82 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
||||
f"Lifespan context does not have 'client' or 'nextcloud_host' attribute. "
|
||||
f"Type: {type(lifespan_ctx)}"
|
||||
)
|
||||
|
||||
|
||||
def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Create NextcloudClient from Smithery session configuration.
|
||||
|
||||
ADR-016: In Smithery stateless mode, each request includes session config
|
||||
with the user's Nextcloud credentials. This function creates a fresh client
|
||||
for each request - no state is persisted between requests.
|
||||
|
||||
For container runtime, config is extracted from URL query parameters by
|
||||
SmitheryConfigMiddleware and stored in a context variable.
|
||||
|
||||
Expected session config fields (from Smithery configSchema):
|
||||
- nextcloud_url: str - Nextcloud instance URL (required)
|
||||
- username: str - Nextcloud username (required)
|
||||
- app_password: str - Nextcloud app password (required)
|
||||
|
||||
Args:
|
||||
ctx: MCP request context (not used directly for Smithery config)
|
||||
|
||||
Returns:
|
||||
NextcloudClient configured with session credentials
|
||||
|
||||
Raises:
|
||||
ValueError: If required session config fields are missing
|
||||
"""
|
||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
||||
|
||||
session_config = get_smithery_session_config()
|
||||
|
||||
if session_config is None:
|
||||
raise ValueError(
|
||||
"Session configuration required in Smithery mode. "
|
||||
"Ensure nextcloud_url, username, and app_password are provided as URL query parameters."
|
||||
)
|
||||
|
||||
# Extract required fields - config is always a dict from SmitheryConfigMiddleware
|
||||
nextcloud_url = session_config.get("nextcloud_url")
|
||||
username = session_config.get("username")
|
||||
app_password = session_config.get("app_password")
|
||||
|
||||
# Validate required fields
|
||||
missing_fields = []
|
||||
if not nextcloud_url:
|
||||
missing_fields.append("nextcloud_url")
|
||||
if not username:
|
||||
missing_fields.append("username")
|
||||
if not app_password:
|
||||
missing_fields.append("app_password")
|
||||
|
||||
if missing_fields:
|
||||
raise ValueError(
|
||||
f"Missing required session config fields: {', '.join(missing_fields)}. "
|
||||
f"Configure these in the Smithery connection settings."
|
||||
)
|
||||
|
||||
# Type assertions after validation (for type checker)
|
||||
# These are guaranteed to be str after the missing_fields check above
|
||||
assert nextcloud_url is not None
|
||||
assert username is not None
|
||||
assert app_password is not None
|
||||
|
||||
# Validate URL format
|
||||
if not nextcloud_url.startswith(("http://", "https://")):
|
||||
raise ValueError(
|
||||
f"Invalid nextcloud_url: {nextcloud_url}. "
|
||||
f"Must start with http:// or https://"
|
||||
)
|
||||
|
||||
logger.debug(f"Creating Smithery client for {nextcloud_url} as {username}")
|
||||
|
||||
# Create client with session credentials using BasicAuth
|
||||
return NextcloudClient(
|
||||
base_url=nextcloud_url,
|
||||
username=username,
|
||||
auth=BasicAuth(username, app_password),
|
||||
)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
"""Document processing plugins for extracting text from various file formats."""
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
from .pymupdf import PyMuPDFProcessor
|
||||
from .registry import ProcessorRegistry, get_registry
|
||||
|
||||
# Register processors at module initialization
|
||||
_registry = get_registry()
|
||||
_registry.register(PyMuPDFProcessor(), priority=10)
|
||||
|
||||
__all__ = [
|
||||
"DocumentProcessor",
|
||||
"ProcessingResult",
|
||||
"ProcessorError",
|
||||
"ProcessorRegistry",
|
||||
"get_registry",
|
||||
"PyMuPDFProcessor",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
"""Document processor using PyMuPDF (fitz) library."""
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import tempfile
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Optional
|
||||
|
||||
# NOTE: Do NOT call pymupdf.layout.activate() here!
|
||||
# It changes the behavior of pymupdf4llm.to_markdown() when page_chunks=True,
|
||||
# causing it to return a string instead of a list[dict].
|
||||
# See: https://github.com/pymupdf/pymupdf4llm/issues/323
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
from .base import DocumentProcessor, ProcessingResult, ProcessorError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PyMuPDFProcessor(DocumentProcessor):
|
||||
"""Document processor using PyMuPDF library for PDF processing.
|
||||
|
||||
PyMuPDF (fitz) is a fast, local PDF processing library that extracts text,
|
||||
metadata, and images without requiring external API calls.
|
||||
|
||||
Features:
|
||||
- Fast text extraction with layout preservation
|
||||
- PDF metadata extraction (title, author, creation date, page count)
|
||||
- Image extraction for future multimodal support
|
||||
- Page number tracking for precise citations
|
||||
"""
|
||||
|
||||
SUPPORTED_TYPES = {
|
||||
"application/pdf",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
extract_images: bool = True,
|
||||
image_dir: Optional[str | pathlib.Path] = None,
|
||||
):
|
||||
"""Initialize PyMuPDF processor.
|
||||
|
||||
Args:
|
||||
extract_images: Whether to extract embedded images from PDFs
|
||||
image_dir: Directory to store extracted images (defaults to temp directory)
|
||||
"""
|
||||
self.extract_images = extract_images
|
||||
|
||||
if image_dir is None:
|
||||
self.image_dir = pathlib.Path(tempfile.gettempdir()) / "pdf-images"
|
||||
else:
|
||||
self.image_dir = pathlib.Path(image_dir)
|
||||
|
||||
# Create image directory if it doesn't exist
|
||||
if self.extract_images:
|
||||
self.image_dir.mkdir(exist_ok=True, parents=True)
|
||||
logger.info(
|
||||
f"Initialized PyMuPDFProcessor with image extraction to {self.image_dir}"
|
||||
)
|
||||
else:
|
||||
logger.info("Initialized PyMuPDFProcessor without image extraction")
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "pymupdf"
|
||||
|
||||
@property
|
||||
def supported_mime_types(self) -> set[str]:
|
||||
return self.SUPPORTED_TYPES
|
||||
|
||||
async def process(
|
||||
self,
|
||||
content: bytes,
|
||||
content_type: str,
|
||||
filename: Optional[str] = None,
|
||||
options: Optional[dict[str, Any]] = None,
|
||||
progress_callback: Optional[
|
||||
Callable[[float, Optional[float], Optional[str]], Awaitable[None]]
|
||||
] = None,
|
||||
) -> ProcessingResult:
|
||||
"""Process a PDF document and extract text, metadata, and images.
|
||||
|
||||
Args:
|
||||
content: PDF document bytes
|
||||
content_type: MIME type (should be application/pdf)
|
||||
filename: Optional filename for better error messages
|
||||
options: Processing options (currently unused)
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
ProcessingResult with extracted text and metadata
|
||||
|
||||
Raises:
|
||||
ProcessorError: If PDF processing fails
|
||||
"""
|
||||
import anyio
|
||||
|
||||
try:
|
||||
if progress_callback:
|
||||
await progress_callback(0, 100, "Opening PDF document")
|
||||
|
||||
# Open document and extract metadata in thread
|
||||
doc = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: pymupdf.open("pdf", content)
|
||||
)
|
||||
|
||||
metadata = self._extract_metadata(doc, filename)
|
||||
metadata["file_size"] = len(content)
|
||||
page_count = doc.page_count
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(10, 100, f"Extracting {page_count} pages")
|
||||
|
||||
# Prepare image directory if needed
|
||||
pdf_image_dir = None
|
||||
if self.extract_images:
|
||||
pdf_id = filename.replace("/", "_") if filename else "unknown"
|
||||
pdf_image_dir = self.image_dir / pdf_id
|
||||
pdf_image_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
# Extract all pages in a single call with page_chunks=True
|
||||
def do_extract() -> list[dict[str, Any]]:
|
||||
# When page_chunks=True, to_markdown returns list[dict] not str
|
||||
return pymupdf4llm.to_markdown( # type: ignore[return-value]
|
||||
doc,
|
||||
write_images=self.extract_images,
|
||||
image_path=pdf_image_dir if self.extract_images else None,
|
||||
page_chunks=True,
|
||||
)
|
||||
|
||||
page_chunks: list[dict[str, Any]] = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
do_extract
|
||||
)
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(90, 100, "Building result")
|
||||
|
||||
# Extract page texts and build boundaries from chunks
|
||||
page_texts: list[str] = []
|
||||
page_boundaries: list[dict[str, Any]] = []
|
||||
current_offset = 0
|
||||
for chunk in page_chunks:
|
||||
text = chunk.get("text", "")
|
||||
page_num = chunk.get("metadata", {}).get("page", len(page_texts) + 1)
|
||||
page_texts.append(text)
|
||||
page_boundaries.append(
|
||||
{
|
||||
"page": page_num,
|
||||
"start_offset": current_offset,
|
||||
"end_offset": current_offset + len(text),
|
||||
}
|
||||
)
|
||||
current_offset += len(text)
|
||||
|
||||
# Collect image paths
|
||||
image_paths = []
|
||||
if pdf_image_dir and pdf_image_dir.exists():
|
||||
image_paths = [str(p) for p in pdf_image_dir.glob("*")]
|
||||
|
||||
# Build final text and metadata
|
||||
md_text = "".join(page_texts)
|
||||
metadata["has_images"] = len(image_paths) > 0
|
||||
if image_paths:
|
||||
metadata["image_count"] = len(image_paths)
|
||||
metadata["image_paths"] = image_paths
|
||||
metadata["page_boundaries"] = page_boundaries
|
||||
|
||||
# Close document
|
||||
doc.close()
|
||||
|
||||
if progress_callback:
|
||||
await progress_callback(100, 100, "Processing complete")
|
||||
|
||||
logger.info(
|
||||
f"Successfully processed PDF {filename or '<bytes>'}: "
|
||||
f"{metadata['page_count']} pages, {len(md_text)} chars, "
|
||||
f"{metadata.get('image_count', 0)} images"
|
||||
)
|
||||
|
||||
return ProcessingResult(
|
||||
text=md_text,
|
||||
metadata=metadata,
|
||||
processor=self.name,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Failed to process PDF {filename or '<bytes>'}: {e}"
|
||||
logger.error(error_msg, exc_info=True)
|
||||
raise ProcessorError(error_msg) from e
|
||||
|
||||
def _extract_metadata(
|
||||
self, doc: pymupdf.Document, filename: Optional[str]
|
||||
) -> dict[str, Any]:
|
||||
"""Extract metadata from PDF document.
|
||||
|
||||
Args:
|
||||
doc: Opened PyMuPDF document
|
||||
filename: Optional filename
|
||||
|
||||
Returns:
|
||||
Dictionary with PDF metadata
|
||||
"""
|
||||
metadata: dict[str, Any] = {}
|
||||
|
||||
# Basic document info
|
||||
metadata["page_count"] = doc.page_count
|
||||
metadata["format"] = "PDF 1." + str(
|
||||
doc.pdf_version() if hasattr(doc, "pdf_version") else "?" # type: ignore[call-non-callable]
|
||||
)
|
||||
|
||||
if filename:
|
||||
metadata["filename"] = filename
|
||||
|
||||
# Extract PDF metadata dictionary
|
||||
pdf_metadata = doc.metadata
|
||||
if pdf_metadata:
|
||||
# Standard PDF metadata fields
|
||||
if pdf_metadata.get("title"):
|
||||
metadata["title"] = pdf_metadata["title"]
|
||||
if pdf_metadata.get("author"):
|
||||
metadata["author"] = pdf_metadata["author"]
|
||||
if pdf_metadata.get("subject"):
|
||||
metadata["subject"] = pdf_metadata["subject"]
|
||||
if pdf_metadata.get("keywords"):
|
||||
metadata["keywords"] = pdf_metadata["keywords"]
|
||||
if pdf_metadata.get("creator"):
|
||||
metadata["creator"] = pdf_metadata["creator"]
|
||||
if pdf_metadata.get("producer"):
|
||||
metadata["producer"] = pdf_metadata["producer"]
|
||||
if pdf_metadata.get("creationDate"):
|
||||
metadata["creation_date"] = pdf_metadata["creationDate"]
|
||||
if pdf_metadata.get("modDate"):
|
||||
metadata["modification_date"] = pdf_metadata["modDate"]
|
||||
|
||||
return metadata
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Check if PyMuPDF is available and working.
|
||||
|
||||
Returns:
|
||||
True if processor is ready to use
|
||||
"""
|
||||
try:
|
||||
# Try to create a simple PDF in memory
|
||||
test_doc = pymupdf.open()
|
||||
test_doc.close()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"PyMuPDF health check failed: {e}")
|
||||
return False
|
||||
@@ -37,7 +37,9 @@ class BM25SparseEmbeddingProvider:
|
||||
|
||||
def encode(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text.
|
||||
Generate BM25 sparse embedding for a single text (synchronous).
|
||||
|
||||
Note: For async contexts, prefer encode_async() to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
@@ -53,7 +55,24 @@ class BM25SparseEmbeddingProvider:
|
||||
"values": sparse_embedding.values.tolist(),
|
||||
}
|
||||
|
||||
def encode_batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
||||
async def encode_async(self, text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Generate BM25 sparse embedding for a single text (async).
|
||||
|
||||
Runs CPU-bound BM25 encoding in thread pool to avoid blocking the event loop.
|
||||
|
||||
Args:
|
||||
text: Input text to encode
|
||||
|
||||
Returns:
|
||||
Dictionary with 'indices' and 'values' keys for Qdrant sparse vector
|
||||
"""
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool
|
||||
return await anyio.to_thread.run_sync(lambda: self.encode(text)) # type: ignore[attr-defined]
|
||||
|
||||
async def encode_batch(self, texts: list[str]) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Generate BM25 sparse embeddings for multiple texts (batched).
|
||||
|
||||
@@ -63,7 +82,12 @@ class BM25SparseEmbeddingProvider:
|
||||
Returns:
|
||||
List of dictionaries with 'indices' and 'values' for each text
|
||||
"""
|
||||
sparse_embeddings = list(self.model.embed(texts))
|
||||
import anyio
|
||||
|
||||
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
|
||||
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
|
||||
lambda: list(self.model.embed(texts))
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Database migration utilities for nextcloud-mcp-server.
|
||||
|
||||
This module provides helper functions for managing Alembic database migrations
|
||||
programmatically. It enables automatic migration on application startup and
|
||||
provides CLI integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
||||
"""
|
||||
Get Alembic configuration for programmatic use.
|
||||
|
||||
Works in both development and installed (Docker) modes by using
|
||||
package location instead of alembic.ini file.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file. If None, uses default
|
||||
(/app/data/tokens.db for Docker)
|
||||
|
||||
Returns:
|
||||
Alembic Config object configured for the specified database
|
||||
"""
|
||||
from nextcloud_mcp_server import alembic as alembic_package
|
||||
|
||||
# Use package location (works in both editable and installed modes)
|
||||
if alembic_package.__file__ is None:
|
||||
raise RuntimeError("alembic package __file__ is None")
|
||||
script_location = Path(alembic_package.__file__).parent
|
||||
|
||||
# Create config programmatically (no alembic.ini needed at runtime)
|
||||
config = Config()
|
||||
config.set_main_option("script_location", str(script_location))
|
||||
config.set_main_option("path_separator", "os") # Suppress deprecation warning
|
||||
|
||||
# Set database URL
|
||||
if database_path:
|
||||
db_path = Path(database_path).resolve()
|
||||
else:
|
||||
db_path = Path("/app/data/tokens.db") # Default for Docker
|
||||
|
||||
url = f"sqlite+aiosqlite:///{db_path}"
|
||||
config.set_main_option("sqlalchemy.url", url)
|
||||
|
||||
logger.debug(f"Alembic script location: {script_location}")
|
||||
logger.debug(f"Database: {db_path}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def upgrade_database(
|
||||
database_path: str | Path | None = None, revision: str = "head"
|
||||
) -> None:
|
||||
"""
|
||||
Upgrade database to a specific revision.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file
|
||||
revision: Target revision (default: "head" for latest)
|
||||
"""
|
||||
config = get_alembic_config(database_path)
|
||||
logger.info(f"Upgrading database to revision: {revision}")
|
||||
command.upgrade(config, revision)
|
||||
logger.info("Database upgrade completed successfully")
|
||||
|
||||
|
||||
def downgrade_database(
|
||||
database_path: str | Path | None = None, revision: str = "-1"
|
||||
) -> None:
|
||||
"""
|
||||
Downgrade database to a specific revision.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file
|
||||
revision: Target revision (default: "-1" for previous version)
|
||||
"""
|
||||
config = get_alembic_config(database_path)
|
||||
logger.warning(f"Downgrading database to revision: {revision}")
|
||||
command.downgrade(config, revision)
|
||||
logger.info("Database downgrade completed successfully")
|
||||
|
||||
|
||||
def get_current_revision(database_path: str | Path | None = None) -> str | None:
|
||||
"""
|
||||
Get the current database revision by directly querying the alembic_version table.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file
|
||||
|
||||
Returns:
|
||||
Current revision ID or None if not versioned
|
||||
"""
|
||||
import sqlite3
|
||||
|
||||
if database_path is None:
|
||||
database_path = "/app/data/tokens.db"
|
||||
|
||||
db_path = Path(database_path).resolve()
|
||||
|
||||
if not db_path.exists():
|
||||
logger.debug(f"Database does not exist: {db_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Query alembic_version table directly
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if alembic_version table exists
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
|
||||
)
|
||||
has_table = cursor.fetchone() is not None
|
||||
|
||||
if not has_table:
|
||||
conn.close()
|
||||
return None
|
||||
|
||||
# Get current version
|
||||
cursor.execute("SELECT version_num FROM alembic_version")
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
|
||||
return row[0] if row else None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get current revision: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def stamp_database(
|
||||
database_path: str | Path | None = None, revision: str = "head"
|
||||
) -> None:
|
||||
"""
|
||||
Stamp database with a specific revision without running migrations.
|
||||
|
||||
This is useful for marking existing databases that were created before
|
||||
Alembic was introduced. It tells Alembic "this database is at revision X"
|
||||
without actually running the migration.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file
|
||||
revision: Revision to stamp (default: "head" for latest)
|
||||
"""
|
||||
config = get_alembic_config(database_path)
|
||||
logger.info(f"Stamping database with revision: {revision}")
|
||||
command.stamp(config, revision)
|
||||
logger.info("Database stamped successfully")
|
||||
|
||||
|
||||
def show_migration_history(database_path: str | Path | None = None) -> None:
|
||||
"""
|
||||
Display migration history.
|
||||
|
||||
Args:
|
||||
database_path: Path to SQLite database file
|
||||
"""
|
||||
config = get_alembic_config(database_path)
|
||||
command.history(config, verbose=True)
|
||||
|
||||
|
||||
def create_migration(message: str, autogenerate: bool = False) -> None:
|
||||
"""
|
||||
Create a new migration script.
|
||||
|
||||
Args:
|
||||
message: Description of the migration
|
||||
autogenerate: Whether to attempt auto-generation (requires SQLAlchemy models)
|
||||
|
||||
Note:
|
||||
Since we don't use SQLAlchemy models, autogenerate will be disabled
|
||||
and migrations must be written manually.
|
||||
"""
|
||||
config = get_alembic_config()
|
||||
logger.info(f"Creating new migration: {message}")
|
||||
|
||||
if autogenerate:
|
||||
logger.warning(
|
||||
"Auto-generation is not supported (no SQLAlchemy models). "
|
||||
"Migration will be created with empty upgrade/downgrade functions."
|
||||
)
|
||||
|
||||
command.revision(config, message=message, autogenerate=False)
|
||||
logger.info("Migration created successfully. Edit the file to add SQL statements.")
|
||||
@@ -0,0 +1,170 @@
|
||||
"""Pydantic models for Nextcloud News app responses."""
|
||||
|
||||
from typing import List
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .base import BaseResponse
|
||||
|
||||
|
||||
class NewsFolder(BaseModel):
|
||||
"""Model for a News folder."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Folder ID")
|
||||
name: str = Field(description="Folder name")
|
||||
|
||||
|
||||
class NewsFeed(BaseModel):
|
||||
"""Model for a News feed (RSS/Atom subscription)."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Feed ID")
|
||||
url: str = Field(description="Feed URL")
|
||||
title: str = Field(description="Feed title")
|
||||
favicon_link: str | None = Field(
|
||||
None, alias="faviconLink", description="Favicon URL"
|
||||
)
|
||||
link: str | None = Field(None, description="Website link")
|
||||
added: int = Field(description="Unix timestamp when feed was added")
|
||||
folder_id: int | None = Field(
|
||||
None, alias="folderId", description="Parent folder ID"
|
||||
)
|
||||
unread_count: int = Field(
|
||||
0, alias="unreadCount", description="Number of unread items"
|
||||
)
|
||||
ordering: int = Field(
|
||||
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
|
||||
)
|
||||
pinned: bool = Field(False, description="Whether feed is pinned to top")
|
||||
update_error_count: int = Field(
|
||||
0, alias="updateErrorCount", description="Consecutive update failures"
|
||||
)
|
||||
last_update_error: str | None = Field(
|
||||
None, alias="lastUpdateError", description="Last update error message"
|
||||
)
|
||||
|
||||
@property
|
||||
def has_errors(self) -> bool:
|
||||
"""Check if feed has update errors."""
|
||||
return self.update_error_count > 0
|
||||
|
||||
|
||||
class NewsItem(BaseModel):
|
||||
"""Model for a News item (article) with full content."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Item ID")
|
||||
guid: str = Field(description="Globally unique identifier")
|
||||
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
|
||||
url: str | None = Field(None, description="Article URL")
|
||||
title: str = Field(description="Article title")
|
||||
author: str | None = Field(None, description="Article author")
|
||||
pub_date: int | None = Field(
|
||||
None, alias="pubDate", description="Publication timestamp"
|
||||
)
|
||||
body: str | None = Field(None, description="Article content (HTML)")
|
||||
enclosure_mime: str | None = Field(
|
||||
None, alias="enclosureMime", description="Enclosure MIME type"
|
||||
)
|
||||
enclosure_link: str | None = Field(
|
||||
None, alias="enclosureLink", description="Enclosure URL"
|
||||
)
|
||||
media_thumbnail: str | None = Field(
|
||||
None, alias="mediaThumbnail", description="Media thumbnail URL"
|
||||
)
|
||||
media_description: str | None = Field(
|
||||
None, alias="mediaDescription", description="Media description"
|
||||
)
|
||||
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||
unread: bool = Field(True, description="Whether item is unread")
|
||||
starred: bool = Field(False, description="Whether item is starred")
|
||||
rtl: bool = Field(False, description="Right-to-left text")
|
||||
last_modified: int = Field(
|
||||
alias="lastModified", description="Last modification timestamp"
|
||||
)
|
||||
fingerprint: str | None = Field(
|
||||
None, description="Content fingerprint for deduplication"
|
||||
)
|
||||
content_hash: str | None = Field(
|
||||
None, alias="contentHash", description="Content hash"
|
||||
)
|
||||
|
||||
|
||||
class NewsItemSummary(BaseModel):
|
||||
"""Lightweight model for News item list responses."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
id: int = Field(description="Item ID")
|
||||
title: str = Field(description="Article title")
|
||||
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||
unread: bool = Field(True, description="Whether item is unread")
|
||||
starred: bool = Field(False, description="Whether item is starred")
|
||||
pub_date: int | None = Field(
|
||||
None, alias="pubDate", description="Publication timestamp"
|
||||
)
|
||||
url: str | None = Field(None, description="Article URL")
|
||||
author: str | None = Field(None, description="Article author")
|
||||
|
||||
|
||||
class NewsStatus(BaseModel):
|
||||
"""Model for News app status."""
|
||||
|
||||
version: str = Field(description="News app version")
|
||||
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||
|
||||
|
||||
# --- Response Models ---
|
||||
|
||||
|
||||
class ListFoldersResponse(BaseResponse):
|
||||
"""Response model for listing folders."""
|
||||
|
||||
results: List[NewsFolder] = Field(description="List of folders")
|
||||
total_count: int = Field(description="Total number of folders")
|
||||
|
||||
|
||||
class ListFeedsResponse(BaseResponse):
|
||||
"""Response model for listing feeds."""
|
||||
|
||||
results: List[NewsFeed] = Field(description="List of feeds")
|
||||
starred_count: int = Field(0, description="Number of starred items")
|
||||
newest_item_id: int | None = Field(None, description="ID of newest item")
|
||||
total_count: int = Field(description="Total number of feeds")
|
||||
|
||||
|
||||
class ListItemsResponse(BaseResponse):
|
||||
"""Response model for listing items."""
|
||||
|
||||
results: List[NewsItemSummary] = Field(description="List of items")
|
||||
total_count: int = Field(description="Number of items returned")
|
||||
has_more: bool = Field(False, description="Whether more items exist")
|
||||
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
|
||||
|
||||
|
||||
class GetItemResponse(BaseResponse):
|
||||
"""Response model for getting a single item."""
|
||||
|
||||
item: NewsItem = Field(description="Full item details")
|
||||
|
||||
|
||||
class FeedHealthResponse(BaseResponse):
|
||||
"""Response model for feed health status."""
|
||||
|
||||
feed_id: int = Field(description="Feed ID")
|
||||
title: str = Field(description="Feed title")
|
||||
url: str = Field(description="Feed URL")
|
||||
has_errors: bool = Field(description="Whether feed has update errors")
|
||||
error_count: int = Field(description="Number of consecutive errors")
|
||||
last_error: str | None = Field(None, description="Last error message")
|
||||
|
||||
|
||||
class GetStatusResponse(BaseResponse):
|
||||
"""Response model for app status."""
|
||||
|
||||
version: str = Field(description="News app version")
|
||||
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||
@@ -10,7 +10,7 @@ from .base import BaseResponse
|
||||
class SemanticSearchResult(BaseModel):
|
||||
"""Model for semantic search results with additional metadata."""
|
||||
|
||||
id: int = Field(description="Document ID")
|
||||
id: int = Field(description="Document ID (int for all document types)")
|
||||
doc_type: str = Field(
|
||||
description="Document type (note, calendar_event, deck_card, etc.)"
|
||||
)
|
||||
@@ -19,9 +19,48 @@ class SemanticSearchResult(BaseModel):
|
||||
default="", description="Document category (notes) or location (calendar)"
|
||||
)
|
||||
excerpt: str = Field(description="Excerpt from matching chunk")
|
||||
score: float = Field(description="Semantic similarity score (0-1)")
|
||||
score: float = Field(
|
||||
description=(
|
||||
"Relevance score (≥ 0.0, higher is better). "
|
||||
"Score range depends on fusion method: "
|
||||
"RRF produces scores in [0.0, 1.0], "
|
||||
"DBSF can exceed 1.0 (sum of normalized scores from multiple systems)"
|
||||
)
|
||||
)
|
||||
chunk_index: int = Field(description="Index of matching chunk in document")
|
||||
total_chunks: int = Field(description="Total number of chunks in document")
|
||||
chunk_start_offset: Optional[int] = Field(
|
||||
default=None, description="Character position where chunk starts in document"
|
||||
)
|
||||
chunk_end_offset: Optional[int] = Field(
|
||||
default=None, description="Character position where chunk ends in document"
|
||||
)
|
||||
page_number: Optional[int] = Field(
|
||||
default=None, description="Page number for PDF documents"
|
||||
)
|
||||
page_count: Optional[int] = Field(
|
||||
default=None, description="Total number of pages in PDF document"
|
||||
)
|
||||
# Context expansion fields (optional, populated when include_context=True)
|
||||
has_context_expansion: bool = Field(
|
||||
default=False, description="Whether context expansion was performed"
|
||||
)
|
||||
marked_text: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Full text with position markers around matched chunk",
|
||||
)
|
||||
before_context: Optional[str] = Field(
|
||||
default=None, description="Text before the matched chunk"
|
||||
)
|
||||
after_context: Optional[str] = Field(
|
||||
default=None, description="Text after the matched chunk"
|
||||
)
|
||||
has_before_truncation: Optional[bool] = Field(
|
||||
default=None, description="Whether before_context was truncated"
|
||||
)
|
||||
has_after_truncation: Optional[bool] = Field(
|
||||
default=None, description="Whether after_context was truncated"
|
||||
)
|
||||
|
||||
|
||||
class SemanticSearchResponse(BaseResponse):
|
||||
|
||||
@@ -37,7 +37,7 @@ class HealthCheckFilter(logging.Filter):
|
||||
"""
|
||||
# Check if the log message contains health check endpoints
|
||||
message = record.getMessage()
|
||||
return not any(
|
||||
health_check = any(
|
||||
endpoint in message
|
||||
for endpoint in [
|
||||
"/health/live",
|
||||
@@ -47,6 +47,8 @@ class HealthCheckFilter(logging.Filter):
|
||||
]
|
||||
)
|
||||
|
||||
return not health_check
|
||||
|
||||
|
||||
class TraceContextFormatter(JsonFormatter):
|
||||
"""
|
||||
@@ -58,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
||||
|
||||
def add_fields(
|
||||
self,
|
||||
log_record: dict[str, Any],
|
||||
log_data: dict[str, Any],
|
||||
record: logging.LogRecord,
|
||||
message_dict: dict[str, Any],
|
||||
) -> None:
|
||||
@@ -66,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
||||
Add custom fields to the log record, including trace context.
|
||||
|
||||
Args:
|
||||
log_record: Dictionary to be serialized as JSON
|
||||
log_data: Dictionary to be serialized as JSON
|
||||
record: LogRecord instance
|
||||
message_dict: Dictionary of extra fields from log call
|
||||
"""
|
||||
# Call parent to add standard fields
|
||||
super().add_fields(log_record, record, message_dict)
|
||||
super().add_fields(log_data, record, message_dict)
|
||||
|
||||
# Add trace context if available
|
||||
trace_context = get_trace_context()
|
||||
if trace_context:
|
||||
log_record["trace_id"] = trace_context.get("trace_id")
|
||||
log_record["span_id"] = trace_context.get("span_id")
|
||||
log_data["trace_id"] = trace_context.get("trace_id")
|
||||
log_data["span_id"] = trace_context.get("span_id")
|
||||
|
||||
# Add standard fields with consistent naming
|
||||
log_record["timestamp"] = self.formatTime(record)
|
||||
log_record["level"] = record.levelname
|
||||
log_record["logger"] = record.name
|
||||
log_record["message"] = record.getMessage()
|
||||
log_data["timestamp"] = self.formatTime(record)
|
||||
log_data["level"] = record.levelname
|
||||
log_data["logger"] = record.name
|
||||
log_data["message"] = record.getMessage()
|
||||
|
||||
# Include exception info if present
|
||||
if record.exc_info:
|
||||
log_record["exception"] = self.formatException(record.exc_info)
|
||||
log_data["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
|
||||
class TraceContextTextFormatter(logging.Formatter):
|
||||
|
||||
@@ -53,10 +53,11 @@ def setup_tracing(
|
||||
global _tracer
|
||||
|
||||
# Create resource with service name
|
||||
pkg_name = __package__.split(".")[0] if __package__ else "nextcloud_mcp_server"
|
||||
resource = Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.version": version(__package__.split(".")[0]),
|
||||
"service.version": version(pkg_name),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ from .anthropic import AnthropicProvider
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .registry import get_provider, reset_provider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
__all__ = [
|
||||
"Provider",
|
||||
"OllamaProvider",
|
||||
"OpenAIProvider",
|
||||
"AnthropicProvider",
|
||||
"SimpleProvider",
|
||||
"BedrockProvider",
|
||||
|
||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
|
||||
def __init__(
|
||||
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
|
||||
):
|
||||
"""
|
||||
Initialize Anthropic provider.
|
||||
|
||||
Args:
|
||||
api_key: Anthropic API key
|
||||
model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||
"""
|
||||
self.client = AsyncAnthropic(api_key=api_key)
|
||||
self.model = model
|
||||
self.model = generation_model
|
||||
|
||||
logger.info(f"Initialized Anthropic provider (model={model})")
|
||||
logger.info(f"Initialized Anthropic provider (model={self.model})")
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
|
||||
@@ -92,14 +92,21 @@ class OllamaProvider(Provider):
|
||||
response.raise_for_status()
|
||||
return response.json()["embedding"]
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
async def embed_batch(
|
||||
self, texts: list[str], batch_size: int = 32
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts (batched requests).
|
||||
Generate embeddings for multiple texts using Ollama's batch API.
|
||||
|
||||
Note: Ollama doesn't have native batch API, so we send requests sequentially.
|
||||
Uses /api/embed endpoint with array input for efficient batch processing.
|
||||
Conservative batch size (32) prevents quality degradation observed in
|
||||
Ollama issue #6262 with larger batches.
|
||||
|
||||
Note: Ollama processes batches serially, not in parallel.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
batch_size: Maximum texts per batch (default: 32)
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
@@ -112,11 +119,17 @@ class OllamaProvider(Provider):
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
embeddings = []
|
||||
for text in texts:
|
||||
embedding = await self.embed(text)
|
||||
embeddings.append(embedding)
|
||||
return embeddings
|
||||
all_embeddings = []
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
response = await self.client.post(
|
||||
f"{self.base_url}/api/embed",
|
||||
json={"model": self.embedding_model, "input": batch},
|
||||
)
|
||||
response.raise_for_status()
|
||||
all_embeddings.extend(response.json()["embeddings"])
|
||||
|
||||
return all_embeddings
|
||||
|
||||
async def _detect_dimension(self):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Unified OpenAI provider for embeddings and text generation.
|
||||
|
||||
Supports:
|
||||
- OpenAI's standard API
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API via base_url override
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import wraps
|
||||
|
||||
import anyio
|
||||
from openai import AsyncOpenAI, RateLimitError
|
||||
|
||||
from .base import Provider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limit retry configuration
|
||||
MAX_RETRIES = 5
|
||||
INITIAL_RETRY_DELAY = 2.0 # seconds
|
||||
MAX_RETRY_DELAY = 60.0 # seconds
|
||||
|
||||
|
||||
def retry_on_rate_limit(func):
|
||||
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retry_delay = INITIAL_RETRY_DELAY
|
||||
last_error: Exception | None = None
|
||||
|
||||
for attempt in range(1, MAX_RETRIES + 1):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except RateLimitError as e:
|
||||
last_error = e
|
||||
if attempt < MAX_RETRIES:
|
||||
logger.warning(
|
||||
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
|
||||
f"retrying in {retry_delay:.1f}s..."
|
||||
)
|
||||
await anyio.sleep(retry_delay)
|
||||
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
|
||||
|
||||
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
|
||||
raise last_error # type: ignore[misc]
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# Well-known embedding dimensions for OpenAI models
|
||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||
"text-embedding-3-small": 1536,
|
||||
"text-embedding-3-large": 3072,
|
||||
"text-embedding-ada-002": 1536,
|
||||
# GitHub Models API uses openai/ prefix
|
||||
"openai/text-embedding-3-small": 1536,
|
||||
"openai/text-embedding-3-large": 3072,
|
||||
}
|
||||
|
||||
|
||||
class OpenAIProvider(Provider):
|
||||
"""
|
||||
OpenAI provider supporting both embeddings and text generation.
|
||||
|
||||
Works with:
|
||||
- OpenAI's standard API (api.openai.com)
|
||||
- GitHub Models API (models.github.ai)
|
||||
- Any OpenAI-compatible API (via base_url)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str | None = None,
|
||||
embedding_model: str | None = None,
|
||||
generation_model: str | None = None,
|
||||
timeout: float = 120.0,
|
||||
):
|
||||
"""
|
||||
Initialize OpenAI provider.
|
||||
|
||||
Args:
|
||||
api_key: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
base_url: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
embedding_model: Model for embeddings (e.g., "text-embedding-3-small").
|
||||
None disables embeddings.
|
||||
generation_model: Model for text generation (e.g., "gpt-4o-mini").
|
||||
None disables generation.
|
||||
timeout: HTTP timeout in seconds (default: 120)
|
||||
"""
|
||||
self.embedding_model = embedding_model
|
||||
self.generation_model = generation_model
|
||||
self._dimension: int | None = None
|
||||
|
||||
# Initialize async client
|
||||
self.client = AsyncOpenAI(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Try to get known dimension without API call
|
||||
if embedding_model and embedding_model in OPENAI_EMBEDDING_DIMENSIONS:
|
||||
self._dimension = OPENAI_EMBEDDING_DIMENSIONS[embedding_model]
|
||||
|
||||
logger.info(
|
||||
f"Initialized OpenAI provider: base_url={base_url or 'default'} "
|
||||
f"(embedding_model={embedding_model}, generation_model={generation_model}, "
|
||||
f"dimension={self._dimension})"
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_embeddings(self) -> bool:
|
||||
"""Whether this provider supports embedding generation."""
|
||||
return self.embedding_model is not None
|
||||
|
||||
@property
|
||||
def supports_generation(self) -> bool:
|
||||
"""Whether this provider supports text generation."""
|
||||
return self.generation_model is not None
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def embed(self, text: str) -> list[float]:
|
||||
"""
|
||||
Generate embedding vector for text.
|
||||
|
||||
Args:
|
||||
text: Input text to embed
|
||||
|
||||
Returns:
|
||||
Vector embedding as list of floats
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=text,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
|
||||
embedding = response.data[0].embedding
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None:
|
||||
self._dimension = len(embedding)
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return embedding
|
||||
|
||||
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings for multiple texts using OpenAI's batch API.
|
||||
|
||||
OpenAI supports up to 2048 inputs per request.
|
||||
|
||||
Args:
|
||||
texts: List of texts to embed
|
||||
|
||||
Returns:
|
||||
List of vector embeddings
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
# OpenAI supports batches up to 2048, but use smaller batches for safety
|
||||
batch_size = 100
|
||||
all_embeddings: list[list[float]] = []
|
||||
|
||||
for i in range(0, len(texts), batch_size):
|
||||
batch = texts[i : i + batch_size]
|
||||
|
||||
# Use helper method with retry logic for each batch
|
||||
batch_embeddings = await self._embed_batch_request(batch)
|
||||
all_embeddings.extend(batch_embeddings)
|
||||
|
||||
# Update dimension if not set
|
||||
if self._dimension is None and batch_embeddings:
|
||||
self._dimension = len(batch_embeddings[0])
|
||||
logger.info(
|
||||
f"Detected embedding dimension: {self._dimension} "
|
||||
f"for model {self.embedding_model}"
|
||||
)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
|
||||
"""Make a single batch embedding request with retry logic."""
|
||||
assert self.embedding_model is not None # Type narrowing
|
||||
response = await self.client.embeddings.create(
|
||||
input=batch,
|
||||
model=self.embedding_model,
|
||||
)
|
||||
# Sort by index to maintain order
|
||||
sorted_data = sorted(response.data, key=lambda x: x.index)
|
||||
return [item.embedding for item in sorted_data]
|
||||
|
||||
def get_dimension(self) -> int:
|
||||
"""
|
||||
Get embedding dimension.
|
||||
|
||||
Returns:
|
||||
Vector dimension for the configured embedding model
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If embeddings not enabled (no embedding_model)
|
||||
RuntimeError: If dimension not detected yet (call embed first)
|
||||
"""
|
||||
if not self.supports_embeddings:
|
||||
raise NotImplementedError(
|
||||
"Embedding not supported - no embedding_model configured"
|
||||
)
|
||||
|
||||
if self._dimension is None:
|
||||
raise RuntimeError(
|
||||
f"Embedding dimension not detected yet for model {self.embedding_model}. "
|
||||
"Call embed() first or use a known model."
|
||||
)
|
||||
return self._dimension
|
||||
|
||||
@retry_on_rate_limit
|
||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||
"""
|
||||
Generate text from a prompt.
|
||||
|
||||
Args:
|
||||
prompt: The prompt to generate from
|
||||
max_tokens: Maximum tokens to generate
|
||||
|
||||
Returns:
|
||||
Generated text
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If generation not enabled (no generation_model)
|
||||
"""
|
||||
if not self.supports_generation:
|
||||
raise NotImplementedError(
|
||||
"Text generation not supported - no generation_model configured"
|
||||
)
|
||||
|
||||
response = await self.client.chat.completions.create(
|
||||
model=self.generation_model,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=max_tokens,
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
return response.choices[0].message.content or ""
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close HTTP client."""
|
||||
await self.client.close()
|
||||
@@ -6,6 +6,7 @@ import os
|
||||
from .base import Provider
|
||||
from .bedrock import BedrockProvider
|
||||
from .ollama import OllamaProvider
|
||||
from .openai import OpenAIProvider
|
||||
from .simple import SimpleProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,8 +18,9 @@ class ProviderRegistry:
|
||||
|
||||
Checks environment variables in priority order and creates appropriate provider:
|
||||
1. Bedrock (AWS_REGION + BEDROCK_*_MODEL)
|
||||
2. Ollama (OLLAMA_BASE_URL)
|
||||
3. Simple (fallback for testing/development)
|
||||
2. OpenAI (OPENAI_API_KEY)
|
||||
3. Ollama (OLLAMA_BASE_URL)
|
||||
4. Simple (fallback for testing/development)
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
@@ -28,8 +30,9 @@ class ProviderRegistry:
|
||||
|
||||
Priority order:
|
||||
1. Bedrock - if AWS_REGION or BEDROCK_EMBEDDING_MODEL is set
|
||||
2. Ollama - if OLLAMA_BASE_URL is set
|
||||
3. Simple - fallback for testing/development
|
||||
2. OpenAI - if OPENAI_API_KEY is set
|
||||
3. Ollama - if OLLAMA_BASE_URL is set
|
||||
4. Simple - fallback for testing/development
|
||||
|
||||
Returns:
|
||||
Provider instance
|
||||
@@ -42,6 +45,12 @@ class ProviderRegistry:
|
||||
- BEDROCK_EMBEDDING_MODEL: Model ID for embeddings (e.g., "amazon.titan-embed-text-v2:0")
|
||||
- BEDROCK_GENERATION_MODEL: Model ID for text generation (e.g., "anthropic.claude-3-sonnet-20240229-v1:0")
|
||||
|
||||
OpenAI:
|
||||
- OPENAI_API_KEY: OpenAI API key (or GITHUB_TOKEN for GitHub Models)
|
||||
- OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
||||
- OPENAI_EMBEDDING_MODEL: Model for embeddings (default: "text-embedding-3-small")
|
||||
- OPENAI_GENERATION_MODEL: Model for text generation (e.g., "gpt-4o-mini")
|
||||
|
||||
Ollama:
|
||||
- OLLAMA_BASE_URL: Ollama API base URL (e.g., "http://localhost:11434")
|
||||
- OLLAMA_EMBEDDING_MODEL: Model for embeddings (default: "nomic-embed-text")
|
||||
@@ -70,7 +79,28 @@ class ProviderRegistry:
|
||||
aws_secret_access_key=os.getenv("AWS_SECRET_ACCESS_KEY"),
|
||||
)
|
||||
|
||||
# 2. Check for Ollama
|
||||
# 2. Check for OpenAI
|
||||
openai_api_key = os.getenv("OPENAI_API_KEY")
|
||||
if openai_api_key:
|
||||
base_url = os.getenv("OPENAI_BASE_URL")
|
||||
embedding_model = os.getenv(
|
||||
"OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"
|
||||
)
|
||||
generation_model = os.getenv("OPENAI_GENERATION_MODEL")
|
||||
|
||||
logger.info(
|
||||
f"Using OpenAI provider: base_url={base_url or 'default'}, "
|
||||
f"embedding_model={embedding_model}, "
|
||||
f"generation_model={generation_model}"
|
||||
)
|
||||
return OpenAIProvider(
|
||||
api_key=openai_api_key,
|
||||
base_url=base_url,
|
||||
embedding_model=embedding_model,
|
||||
generation_model=generation_model,
|
||||
)
|
||||
|
||||
# 3. Check for Ollama (local LLM)
|
||||
ollama_url = os.getenv("OLLAMA_BASE_URL")
|
||||
if ollama_url:
|
||||
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
|
||||
@@ -89,12 +119,12 @@ class ProviderRegistry:
|
||||
verify_ssl=verify_ssl,
|
||||
)
|
||||
|
||||
# 3. Fallback to Simple provider for development/testing
|
||||
# 4. Fallback to Simple provider for development/testing
|
||||
dimension = int(os.getenv("SIMPLE_EMBEDDING_DIMENSION", "384"))
|
||||
logger.warning(
|
||||
"No provider configured (AWS_REGION, OLLAMA_BASE_URL not set). "
|
||||
"No provider configured (AWS_REGION, OPENAI_API_KEY, OLLAMA_BASE_URL not set). "
|
||||
"Using SimpleProvider for testing/development. "
|
||||
"For production, configure Bedrock or Ollama."
|
||||
"For production, configure Bedrock, OpenAI, or Ollama."
|
||||
)
|
||||
return SimpleProvider(dimension=dimension)
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -97,17 +98,20 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
|
||||
scroll_results, _next_offset = await qdrant_client.scroll(
|
||||
collection_name=collection,
|
||||
scroll_filter=Filter(
|
||||
must=[FieldCondition(key="user_id", match=MatchValue(value=user_id))]
|
||||
must=[
|
||||
get_placeholder_filter(), # Exclude placeholders from doc_type discovery
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
]
|
||||
),
|
||||
limit=1000, # Sample size to discover types
|
||||
with_payload=["doc_type"],
|
||||
with_vectors=False, # Don't need vectors for type discovery
|
||||
)
|
||||
|
||||
doc_types = {
|
||||
point.payload.get("doc_type")
|
||||
doc_types: set[str] = {
|
||||
str(point.payload.get("doc_type"))
|
||||
for point in scroll_results
|
||||
if point.payload.get("doc_type")
|
||||
if point.payload and point.payload.get("doc_type")
|
||||
}
|
||||
|
||||
logger.debug(f"Found indexed document types for user {user_id}: {doc_types}")
|
||||
@@ -123,12 +127,21 @@ class SearchResult:
|
||||
"""A single search result with metadata and score.
|
||||
|
||||
Attributes:
|
||||
id: Document ID
|
||||
id: Document ID (int for all document types)
|
||||
doc_type: Document type (note, file, calendar, contact, etc.)
|
||||
title: Document title
|
||||
excerpt: Content excerpt showing match context
|
||||
score: Relevance score (0.0-1.0, higher is better)
|
||||
score: Relevance score (≥ 0.0, higher is better)
|
||||
- RRF fusion: scores in [0.0, 1.0]
|
||||
- DBSF fusion: scores can exceed 1.0 (sum of normalized scores)
|
||||
metadata: Additional algorithm-specific metadata
|
||||
chunk_start_offset: Character position where chunk starts (None if not available)
|
||||
chunk_end_offset: Character position where chunk ends (None if not available)
|
||||
page_number: Page number for PDF documents (None for other doc types)
|
||||
page_count: Total number of pages in PDF document (None for other doc types)
|
||||
chunk_index: Zero-based index of this chunk in the document
|
||||
total_chunks: Total number of chunks in the document
|
||||
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
|
||||
"""
|
||||
|
||||
id: int
|
||||
@@ -137,11 +150,25 @@ class SearchResult:
|
||||
excerpt: str
|
||||
score: float
|
||||
metadata: dict[str, Any] | None = None
|
||||
chunk_start_offset: int | None = None
|
||||
chunk_end_offset: int | None = None
|
||||
page_number: int | None = None
|
||||
page_count: int | None = None
|
||||
chunk_index: int = 0
|
||||
total_chunks: int = 1
|
||||
point_id: str | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate score is in valid range."""
|
||||
if not 0.0 <= self.score <= 1.0:
|
||||
raise ValueError(f"Score must be between 0.0 and 1.0, got {self.score}")
|
||||
"""Validate score is non-negative.
|
||||
|
||||
Note: Different fusion methods produce different score ranges:
|
||||
- RRF (Reciprocal Rank Fusion): Bounded to [0.0, 1.0]
|
||||
- DBSF (Distribution-Based Score Fusion): Unbounded (can exceed 1.0)
|
||||
DBSF sums normalized scores from multiple systems, so scores can be
|
||||
1.5, 2.0, etc. when multiple systems agree a document is highly relevant.
|
||||
"""
|
||||
if self.score < 0.0:
|
||||
raise ValueError(f"Score must be non-negative, got {self.score}")
|
||||
|
||||
|
||||
class SearchAlgorithm(ABC):
|
||||
@@ -149,8 +176,15 @@ class SearchAlgorithm(ABC):
|
||||
|
||||
All search algorithms must implement the search() method with consistent
|
||||
interface, allowing them to be used interchangeably.
|
||||
|
||||
Attributes:
|
||||
query_embedding: The query embedding generated during the last search.
|
||||
Available after search() completes for algorithms that use embeddings.
|
||||
Can be reused by callers to avoid redundant embedding generation.
|
||||
"""
|
||||
|
||||
query_embedding: list[float] | None = None
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
|
||||
@@ -9,7 +9,9 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
|
||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
|
||||
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__)
|
||||
@@ -28,15 +30,27 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
eliminating the need for application-layer result merging.
|
||||
"""
|
||||
|
||||
def __init__(self, score_threshold: float = 0.0):
|
||||
def __init__(self, score_threshold: float = 0.0, fusion: str = "rrf"):
|
||||
"""
|
||||
Initialize BM25 hybrid search algorithm.
|
||||
|
||||
Args:
|
||||
score_threshold: Minimum RRF score (0-1, default: 0.0 to allow RRF scoring)
|
||||
Note: RRF produces normalized scores, so threshold is typically lower
|
||||
score_threshold: Minimum fusion score (0-1, default: 0.0 to allow fusion scoring)
|
||||
Note: Both RRF and DBSF produce normalized scores
|
||||
fusion: Fusion algorithm to use: "rrf" (Reciprocal Rank Fusion, default)
|
||||
or "dbsf" (Distribution-Based Score Fusion)
|
||||
|
||||
Raises:
|
||||
ValueError: If fusion is not "rrf" or "dbsf"
|
||||
"""
|
||||
if fusion not in ("rrf", "dbsf"):
|
||||
raise ValueError(
|
||||
f"Invalid fusion algorithm '{fusion}'. Must be 'rrf' or 'dbsf'"
|
||||
)
|
||||
|
||||
self.score_threshold = score_threshold
|
||||
self.fusion = models.Fusion.RRF if fusion == "rrf" else models.Fusion.DBSF
|
||||
self.fusion_name = fusion
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -60,6 +74,9 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
|
||||
Args:
|
||||
query: Natural language or keyword search query
|
||||
user_id: User ID for filtering
|
||||
@@ -78,17 +95,24 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
|
||||
logger.info(
|
||||
f"BM25 hybrid search: query='{query}', user={user_id}, "
|
||||
f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}"
|
||||
f"limit={limit}, score_threshold={score_threshold}, doc_type={doc_type}, "
|
||||
f"fusion={self.fusion_name}"
|
||||
)
|
||||
|
||||
# Generate dense embedding for semantic search
|
||||
embedding_service = get_embedding_service()
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
with trace_operation("search.get_embedding_service"):
|
||||
embedding_service = get_embedding_service()
|
||||
with trace_operation("search.dense_embedding"):
|
||||
dense_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = dense_embedding
|
||||
logger.debug(f"Generated dense embedding (dimension={len(dense_embedding)})")
|
||||
|
||||
# Generate sparse embedding for BM25 keyword search
|
||||
bm25_service = get_bm25_service()
|
||||
sparse_embedding = bm25_service.encode(query)
|
||||
with trace_operation("search.get_bm25_service"):
|
||||
bm25_service = get_bm25_service()
|
||||
with trace_operation("search.sparse_embedding_bm25"):
|
||||
sparse_embedding = await bm25_service.encode_async(query)
|
||||
logger.debug(
|
||||
f"Generated sparse embedding "
|
||||
f"({len(sparse_embedding['indices'])} non-zero terms)"
|
||||
@@ -96,10 +120,11 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
|
||||
# Build Qdrant filter
|
||||
filter_conditions = [
|
||||
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=user_id),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
# Add doc_type filter if specified
|
||||
@@ -114,86 +139,123 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
||||
query_filter = Filter(must=filter_conditions)
|
||||
|
||||
# Execute hybrid search with Qdrant native RRF fusion
|
||||
qdrant_client = await get_qdrant_client()
|
||||
with trace_operation("search.get_qdrant_client"):
|
||||
qdrant_client = await get_qdrant_client()
|
||||
|
||||
try:
|
||||
# Use prefetch to run both dense and sparse searches
|
||||
# Qdrant will automatically merge results using RRF
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
with trace_operation(
|
||||
"search.qdrant_query",
|
||||
attributes={"query.limit": limit * 2, "query.fusion": self.fusion_name},
|
||||
):
|
||||
search_response = await qdrant_client.query_points(
|
||||
collection_name=settings.get_collection_name(),
|
||||
prefetch=[
|
||||
# Dense semantic search
|
||||
models.Prefetch(
|
||||
query=dense_embedding,
|
||||
using="dense",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# RRF fusion query (no additional query needed, just fusion)
|
||||
query=models.FusionQuery(fusion=models.Fusion.RRF),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
# Sparse BM25 search
|
||||
models.Prefetch(
|
||||
query=models.SparseVector(
|
||||
indices=sparse_embedding["indices"],
|
||||
values=sparse_embedding["values"],
|
||||
),
|
||||
using="sparse",
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
filter=query_filter,
|
||||
),
|
||||
],
|
||||
# Fusion query (RRF or DBSF based on initialization)
|
||||
query=models.FusionQuery(fusion=self.fusion),
|
||||
limit=limit * 2, # Get extra for deduplication
|
||||
score_threshold=score_threshold,
|
||||
with_payload=True,
|
||||
with_vectors=False, # Don't return vectors to save bandwidth
|
||||
)
|
||||
record_qdrant_operation("search", "success")
|
||||
except Exception:
|
||||
record_qdrant_operation("search", "error")
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"Qdrant RRF fusion returned {len(search_response.points)} results "
|
||||
f"Qdrant {self.fusion_name.upper()} fusion returned {len(search_response.points)} results "
|
||||
f"(before deduplication)"
|
||||
)
|
||||
|
||||
if search_response.points:
|
||||
# Log top 3 RRF scores to help with threshold tuning
|
||||
# Log top 3 fusion scores to help with threshold tuning
|
||||
top_scores = [p.score for p in search_response.points[:3]]
|
||||
logger.debug(f"Top 3 RRF fusion scores: {top_scores}")
|
||||
|
||||
# Deduplicate by (doc_id, doc_type) - multiple chunks per document
|
||||
seen_docs = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
doc_id = int(result.payload["doc_id"])
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
doc_key = (doc_id, doc_type)
|
||||
|
||||
# Skip if we've already seen this document
|
||||
if doc_key in seen_docs:
|
||||
continue
|
||||
|
||||
seen_docs.add(doc_key)
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # RRF fusion score
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": "bm25_hybrid_rrf",
|
||||
},
|
||||
)
|
||||
logger.debug(
|
||||
f"Top 3 {self.fusion_name.upper()} fusion scores: {top_scores}"
|
||||
)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
|
||||
# This allows multiple chunks from same doc, but removes duplicate chunks
|
||||
with trace_operation(
|
||||
"search.deduplicate",
|
||||
attributes={"dedupe.num_points": len(search_response.points)},
|
||||
):
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
if result.payload is None:
|
||||
continue
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
|
||||
seen_chunks.add(chunk_key)
|
||||
|
||||
# Build metadata dict with common fields
|
||||
metadata = {
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
||||
}
|
||||
|
||||
# Add file-specific metadata for PDF viewer
|
||||
if doc_type == "file" and (path := result.payload.get("file_path")):
|
||||
metadata["path"] = path
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
SearchResult(
|
||||
id=doc_id,
|
||||
doc_type=doc_type,
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score, # Fusion score (RRF or DBSF)
|
||||
metadata=metadata,
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
page_count=result.payload.get("page_count"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"Returning {len(results)} unverified results after deduplication")
|
||||
if results:
|
||||
|
||||
@@ -0,0 +1,748 @@
|
||||
"""Context expansion for search results.
|
||||
|
||||
Provides utilities to expand matched chunks with surrounding context and
|
||||
position markers for better visualization and understanding of search results.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from nextcloud_mcp_server.client import NextcloudClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_chunk_from_qdrant(
|
||||
user_id: str, doc_id: int, doc_type: str, chunk_start: int, chunk_end: int
|
||||
) -> str | None:
|
||||
"""Retrieve full chunk text from Qdrant payload.
|
||||
|
||||
This avoids re-fetching and re-parsing documents by using the cached
|
||||
chunk content already stored in Qdrant.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for the specific chunk
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=chunk_start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=chunk_end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["excerpt"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
excerpt = point.payload.get("excerpt")
|
||||
if excerpt:
|
||||
logger.debug(
|
||||
f"Retrieved chunk from Qdrant for {doc_type} {doc_id}: "
|
||||
f"{len(excerpt)} chars"
|
||||
)
|
||||
return str(excerpt)
|
||||
|
||||
logger.debug(
|
||||
f"Chunk not found in Qdrant for {doc_type} {doc_id}, "
|
||||
f"chunk [{chunk_start}:{chunk_end}]. Will fall back to document fetch."
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error querying Qdrant for chunk: {e}. Falling back to document fetch.",
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_chunk_by_index_from_qdrant(
|
||||
user_id: str, doc_id: int, doc_type: str, chunk_index: int
|
||||
) -> str | None:
|
||||
"""Retrieve chunk text by chunk_index from Qdrant payload.
|
||||
|
||||
Used to fetch adjacent chunks for context expansion.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID
|
||||
doc_type: Document type (e.g., "note", "file")
|
||||
chunk_index: Zero-based chunk index in document
|
||||
|
||||
Returns:
|
||||
Full chunk text from Qdrant excerpt field, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for chunk by index
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=doc_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value=doc_type)),
|
||||
FieldCondition(
|
||||
key="chunk_index", match=MatchValue(value=chunk_index)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["excerpt"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
excerpt = point.payload.get("excerpt")
|
||||
if excerpt:
|
||||
logger.debug(
|
||||
f"Retrieved adjacent chunk {chunk_index} from Qdrant for "
|
||||
f"{doc_type} {doc_id}: {len(excerpt)} chars"
|
||||
)
|
||||
return str(excerpt)
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
f"Could not retrieve adjacent chunk {chunk_index} for "
|
||||
f"{doc_type} {doc_id}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_file_path_from_qdrant(
|
||||
user_id: str, file_id: int, chunk_start: int, chunk_end: int
|
||||
) -> str | None:
|
||||
"""Resolve file_id to file_path by querying Qdrant payload.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the file
|
||||
file_id: Numeric file ID
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
|
||||
Returns:
|
||||
File path string, or None if not found in Qdrant
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for the specific chunk
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=file_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="file")),
|
||||
FieldCondition(
|
||||
key="chunk_start_offset", match=MatchValue(value=chunk_start)
|
||||
),
|
||||
FieldCondition(
|
||||
key="chunk_end_offset", match=MatchValue(value=chunk_end)
|
||||
),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["file_path"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
file_path = point.payload.get("file_path")
|
||||
if file_path:
|
||||
logger.debug(f"Resolved file_id {file_id} to file_path {file_path}")
|
||||
return str(file_path)
|
||||
|
||||
logger.warning(
|
||||
f"Could not find file_path in Qdrant for file_id {file_id}, "
|
||||
f"chunk [{chunk_start}:{chunk_end}]"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error querying Qdrant for file_path: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _get_deck_metadata_from_qdrant(
|
||||
user_id: str, card_id: int
|
||||
) -> dict[str, int] | None:
|
||||
"""Retrieve board_id and stack_id for a deck card from Qdrant payload.
|
||||
|
||||
Args:
|
||||
user_id: User ID who owns the card
|
||||
card_id: Card ID
|
||||
|
||||
Returns:
|
||||
Dictionary with board_id and stack_id, or None if not found
|
||||
"""
|
||||
try:
|
||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||
|
||||
qdrant_client = await get_qdrant_client()
|
||||
settings = get_settings()
|
||||
|
||||
# Query for any chunk of this card (we just need metadata)
|
||||
scroll_result = await qdrant_client.scroll(
|
||||
collection_name=settings.get_collection_name(),
|
||||
scroll_filter=Filter(
|
||||
must=[
|
||||
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
|
||||
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
|
||||
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
with_payload=["board_id", "stack_id"],
|
||||
with_vectors=False,
|
||||
)
|
||||
|
||||
if scroll_result[0]:
|
||||
point = scroll_result[0][0]
|
||||
board_id = point.payload.get("board_id")
|
||||
stack_id = point.payload.get("stack_id")
|
||||
if board_id is not None and stack_id is not None:
|
||||
logger.debug(
|
||||
f"Retrieved deck metadata for card {card_id}: "
|
||||
f"board_id={board_id}, stack_id={stack_id}"
|
||||
)
|
||||
return {"board_id": int(board_id), "stack_id": int(stack_id)}
|
||||
|
||||
logger.debug(
|
||||
f"Could not find deck metadata in Qdrant for card {card_id} "
|
||||
f"(might be legacy data without board_id/stack_id)"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error querying Qdrant for deck metadata: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChunkContext:
|
||||
"""Expanded chunk with surrounding context and position markers.
|
||||
|
||||
Attributes:
|
||||
chunk_text: The matched chunk text
|
||||
before_context: Text before the chunk (up to context_chars)
|
||||
after_context: Text after the chunk (up to context_chars)
|
||||
chunk_start_offset: Character position where chunk starts in document
|
||||
chunk_end_offset: Character position where chunk ends in document
|
||||
page_number: Page number for PDFs (None for other doc types)
|
||||
chunk_index: Zero-based chunk index (N in "chunk N of M")
|
||||
total_chunks: Total number of chunks in document
|
||||
marked_text: Full text with position markers around the chunk
|
||||
has_before_truncation: True if before_context was truncated
|
||||
has_after_truncation: True if after_context was truncated
|
||||
"""
|
||||
|
||||
chunk_text: str
|
||||
before_context: str
|
||||
after_context: str
|
||||
chunk_start_offset: int
|
||||
chunk_end_offset: int
|
||||
page_number: int | None
|
||||
chunk_index: int
|
||||
total_chunks: int
|
||||
marked_text: str
|
||||
has_before_truncation: bool
|
||||
has_after_truncation: bool
|
||||
|
||||
|
||||
async def get_chunk_with_context(
|
||||
nc_client: NextcloudClient,
|
||||
user_id: str,
|
||||
doc_id: str | int,
|
||||
doc_type: str,
|
||||
chunk_start: int,
|
||||
chunk_end: int,
|
||||
page_number: int | None = None,
|
||||
chunk_index: int = 0,
|
||||
total_chunks: int = 1,
|
||||
context_chars: int = 300,
|
||||
) -> ChunkContext | None:
|
||||
"""Fetch chunk with surrounding context.
|
||||
|
||||
First tries to retrieve the chunk from Qdrant (fast, cached). If that fails
|
||||
(e.g., legacy data with truncated excerpts), falls back to fetching and
|
||||
parsing the full document (slower, for PDFs especially).
|
||||
|
||||
Args:
|
||||
nc_client: Authenticated Nextcloud client
|
||||
user_id: User ID who owns the document
|
||||
doc_id: Document ID (int for notes/files)
|
||||
doc_type: Type of document ("note", "file", etc.)
|
||||
chunk_start: Character offset where chunk starts
|
||||
chunk_end: Character offset where chunk ends
|
||||
page_number: Optional page number for PDFs
|
||||
chunk_index: Zero-based chunk index in document
|
||||
total_chunks: Total number of chunks in document
|
||||
context_chars: Number of characters to include before/after chunk
|
||||
|
||||
Returns:
|
||||
ChunkContext with expanded context and markers, or None if document
|
||||
cannot be retrieved
|
||||
"""
|
||||
# Convert doc_id to int for Qdrant query
|
||||
doc_id_int = (
|
||||
int(doc_id)
|
||||
if isinstance(doc_id, str) and doc_id.isdigit()
|
||||
else (doc_id if isinstance(doc_id, int) else None)
|
||||
)
|
||||
|
||||
# Try to get chunk from Qdrant first (fast path)
|
||||
if doc_id_int is not None:
|
||||
chunk_text = await _get_chunk_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_start, chunk_end
|
||||
)
|
||||
if chunk_text:
|
||||
logger.info(
|
||||
f"Retrieved chunk from Qdrant cache for {doc_type} {doc_id} "
|
||||
f"(avoids document re-fetch/re-parse)"
|
||||
)
|
||||
|
||||
# Fetch adjacent chunks for context expansion
|
||||
# Get chunk overlap from config to remove duplicate text
|
||||
from nextcloud_mcp_server.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
chunk_overlap = settings.document_chunk_overlap
|
||||
|
||||
before_context = ""
|
||||
after_context = ""
|
||||
has_before_truncation = False
|
||||
has_after_truncation = False
|
||||
|
||||
# Fetch previous chunk if not first chunk
|
||||
if chunk_index > 0:
|
||||
before_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_index - 1
|
||||
)
|
||||
if before_chunk:
|
||||
# Remove overlap: the last chunk_overlap chars of previous chunk
|
||||
# overlap with the first chunk_overlap chars of current chunk
|
||||
before_context = (
|
||||
before_chunk[:-chunk_overlap]
|
||||
if len(before_chunk) > chunk_overlap
|
||||
else ""
|
||||
)
|
||||
# Truncate if requested context_chars < remaining length
|
||||
if before_context and len(before_context) > context_chars:
|
||||
before_context = before_context[-context_chars:]
|
||||
has_before_truncation = True
|
||||
else:
|
||||
# Could not fetch previous chunk, but we're not at start
|
||||
has_before_truncation = True
|
||||
|
||||
# Fetch next chunk if not last chunk
|
||||
if chunk_index < total_chunks - 1:
|
||||
after_chunk = await _get_chunk_by_index_from_qdrant(
|
||||
user_id, doc_id_int, doc_type, chunk_index + 1
|
||||
)
|
||||
if after_chunk:
|
||||
# Remove overlap: the first chunk_overlap chars of next chunk
|
||||
# overlap with the last chunk_overlap chars of current chunk
|
||||
after_context = (
|
||||
after_chunk[chunk_overlap:]
|
||||
if len(after_chunk) > chunk_overlap
|
||||
else ""
|
||||
)
|
||||
# Truncate if requested context_chars < remaining length
|
||||
if after_context and len(after_context) > context_chars:
|
||||
after_context = after_context[:context_chars]
|
||||
has_after_truncation = True
|
||||
else:
|
||||
# Could not fetch next chunk, but we're not at end
|
||||
has_after_truncation = True
|
||||
|
||||
marked_text = _insert_position_markers(
|
||||
before_context=before_context,
|
||||
chunk_text=chunk_text,
|
||||
after_context=after_context,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
return ChunkContext(
|
||||
chunk_text=chunk_text,
|
||||
before_context=before_context,
|
||||
after_context=after_context,
|
||||
chunk_start_offset=chunk_start,
|
||||
chunk_end_offset=chunk_end,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
marked_text=marked_text,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
# Fallback: Fetch full document and extract chunk with context
|
||||
# This path is taken for:
|
||||
# 1. Legacy data with truncated excerpts in Qdrant
|
||||
# 2. Failed Qdrant queries
|
||||
logger.info(
|
||||
f"Falling back to document fetch for {doc_type} {doc_id} "
|
||||
f"(Qdrant cache miss, possibly legacy data)"
|
||||
)
|
||||
|
||||
# For files, retrieve file_path from Qdrant payload
|
||||
resolved_doc_id = doc_id
|
||||
if doc_type == "file" and isinstance(doc_id, int):
|
||||
file_path = await _get_file_path_from_qdrant(
|
||||
user_id, doc_id, chunk_start, chunk_end
|
||||
)
|
||||
if not file_path:
|
||||
logger.warning(
|
||||
f"Could not resolve file_id {doc_id} to file_path from Qdrant"
|
||||
)
|
||||
return None
|
||||
resolved_doc_id = file_path
|
||||
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
||||
|
||||
# Fetch full document text
|
||||
full_text = await _fetch_document_text(
|
||||
nc_client, resolved_doc_id, doc_type, user_id
|
||||
)
|
||||
if full_text is None:
|
||||
logger.warning(
|
||||
f"Could not fetch document text for {doc_type} {doc_id}, "
|
||||
"skipping context expansion"
|
||||
)
|
||||
return None
|
||||
|
||||
# Validate offsets
|
||||
if chunk_start < 0 or chunk_end > len(full_text) or chunk_start >= chunk_end:
|
||||
logger.warning(
|
||||
f"Invalid chunk offsets for {doc_type} {doc_id}: "
|
||||
f"start={chunk_start}, end={chunk_end}, doc_len={len(full_text)}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Extract chunk text
|
||||
chunk_text = full_text[chunk_start:chunk_end]
|
||||
|
||||
# Calculate context boundaries
|
||||
context_start = max(0, chunk_start - context_chars)
|
||||
context_end = min(len(full_text), chunk_end + context_chars)
|
||||
|
||||
# Extract context
|
||||
before_context = full_text[context_start:chunk_start]
|
||||
after_context = full_text[chunk_end:context_end]
|
||||
|
||||
# Check for truncation
|
||||
has_before_truncation = context_start > 0
|
||||
has_after_truncation = context_end < len(full_text)
|
||||
|
||||
# Create marked text with position markers
|
||||
marked_text = _insert_position_markers(
|
||||
before_context=before_context,
|
||||
chunk_text=chunk_text,
|
||||
after_context=after_context,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
return ChunkContext(
|
||||
chunk_text=chunk_text,
|
||||
before_context=before_context,
|
||||
after_context=after_context,
|
||||
chunk_start_offset=chunk_start,
|
||||
chunk_end_offset=chunk_end,
|
||||
page_number=page_number,
|
||||
chunk_index=chunk_index,
|
||||
total_chunks=total_chunks,
|
||||
marked_text=marked_text,
|
||||
has_before_truncation=has_before_truncation,
|
||||
has_after_truncation=has_after_truncation,
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_document_text(
|
||||
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
|
||||
) -> str | None:
|
||||
"""Fetch full text content of a document.
|
||||
|
||||
Args:
|
||||
nc_client: Authenticated Nextcloud client
|
||||
doc_id: Document ID (note ID or file path)
|
||||
doc_type: Type of document ("note", "file", etc.)
|
||||
|
||||
Returns:
|
||||
Full document text, or None if document cannot be retrieved
|
||||
"""
|
||||
try:
|
||||
if doc_type == "note":
|
||||
# Fetch note by ID
|
||||
note = await nc_client.notes.get_note(note_id=int(doc_id))
|
||||
# Reconstruct full content as indexed: title + "\n\n" + content
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
title = note.get("title", "")
|
||||
content = note.get("content", "")
|
||||
return f"{title}\n\n{content}"
|
||||
elif doc_type == "file":
|
||||
# Fetch file content via WebDAV
|
||||
try:
|
||||
file_path = str(doc_id)
|
||||
file_content, content_type = await nc_client.webdav.read_file(file_path)
|
||||
|
||||
# Check if it's a PDF (by content type or file extension)
|
||||
is_pdf = (
|
||||
content_type and "pdf" in content_type.lower()
|
||||
) or file_path.lower().endswith(".pdf")
|
||||
|
||||
if is_pdf:
|
||||
# Extract text from PDF using PyMuPDF
|
||||
# IMPORTANT: Use pymupdf4llm.to_markdown() to match indexing extraction
|
||||
# This ensures character offsets align between indexed chunks and retrieval
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
logger.debug(f"Extracting text from PDF: {file_path}")
|
||||
pdf_doc = pymupdf.open(stream=file_content, filetype="pdf")
|
||||
text_parts = []
|
||||
|
||||
# Extract each page as markdown (same as indexing)
|
||||
for page_num in range(pdf_doc.page_count):
|
||||
page_md = pymupdf4llm.to_markdown(
|
||||
pdf_doc,
|
||||
pages=[page_num],
|
||||
write_images=False, # Don't need images for context
|
||||
page_chunks=False,
|
||||
)
|
||||
text_parts.append(page_md)
|
||||
|
||||
pdf_doc.close()
|
||||
|
||||
# Join pages (no separator - matches indexing)
|
||||
full_text = "".join(text_parts)
|
||||
logger.debug(
|
||||
f"Extracted {len(full_text)} characters from "
|
||||
f"{pdf_doc.page_count} pages in {file_path}"
|
||||
)
|
||||
return full_text
|
||||
else:
|
||||
# Assume it's a text file, decode to string
|
||||
logger.debug(f"Decoding text file: {file_path}")
|
||||
return file_content.decode("utf-8", errors="replace")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
||||
)
|
||||
return None
|
||||
elif doc_type == "news_item":
|
||||
# Fetch news item by ID
|
||||
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||
|
||||
item = await nc_client.news.get_item(int(doc_id))
|
||||
# Reconstruct full content as indexed: title + source + URL + body
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
body_markdown = html_to_markdown(item.get("body", ""))
|
||||
item_title = item.get("title", "")
|
||||
item_url = item.get("url", "")
|
||||
feed_title = item.get("feedTitle", "")
|
||||
|
||||
content_parts = [item_title]
|
||||
if feed_title:
|
||||
content_parts.append(f"Source: {feed_title}")
|
||||
if item_url:
|
||||
content_parts.append(f"URL: {item_url}")
|
||||
content_parts.append("") # Blank line
|
||||
content_parts.append(body_markdown)
|
||||
return "\n".join(content_parts)
|
||||
elif doc_type == "deck_card":
|
||||
# Fetch card from Deck API
|
||||
# Try to get board_id/stack_id from Qdrant metadata (O(1) lookup)
|
||||
# Otherwise fall back to iteration (legacy data)
|
||||
card = None
|
||||
deck_metadata = await _get_deck_metadata_from_qdrant(user_id, int(doc_id))
|
||||
|
||||
if deck_metadata:
|
||||
# Fast path: Direct lookup with known board_id/stack_id
|
||||
board_id = deck_metadata["board_id"]
|
||||
stack_id = deck_metadata["stack_id"]
|
||||
try:
|
||||
card = await nc_client.deck.get_card(
|
||||
board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
|
||||
)
|
||||
logger.debug(
|
||||
f"Retrieved deck card {doc_id} using metadata "
|
||||
f"(board_id={board_id}, stack_id={stack_id})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to fetch card with metadata (board_id={board_id}, "
|
||||
f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration"
|
||||
)
|
||||
|
||||
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
|
||||
if card is None:
|
||||
boards = await nc_client.deck.get_boards()
|
||||
card_found = False
|
||||
|
||||
for board in boards:
|
||||
if card_found:
|
||||
break
|
||||
|
||||
# Skip deleted boards (soft delete: deletedAt > 0)
|
||||
if board.deletedAt > 0:
|
||||
logger.debug(
|
||||
f"Skipping deleted board {board.id} while searching for card {doc_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
stacks = await nc_client.deck.get_stacks(board.id)
|
||||
|
||||
for stack in stacks:
|
||||
if card_found:
|
||||
break
|
||||
if stack.cards:
|
||||
for c in stack.cards:
|
||||
if c.id == int(doc_id):
|
||||
card = c
|
||||
card_found = True
|
||||
logger.debug(
|
||||
f"Found deck card {doc_id} in board {board.id}, "
|
||||
f"stack {stack.id} (fallback iteration)"
|
||||
)
|
||||
break
|
||||
|
||||
if not card_found:
|
||||
logger.warning(f"Deck card {doc_id} not found in any board/stack")
|
||||
return None
|
||||
|
||||
# Type narrowing: card is set if we reach here
|
||||
assert card is not None
|
||||
|
||||
# Reconstruct full content as indexed: title + "\n\n" + description
|
||||
# This ensures chunk offsets align with indexed content structure
|
||||
content_parts = [card.title]
|
||||
if card.description:
|
||||
content_parts.append(card.description)
|
||||
return "\n\n".join(content_parts)
|
||||
else:
|
||||
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching document {doc_type} {doc_id}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _insert_position_markers(
|
||||
before_context: str,
|
||||
chunk_text: str,
|
||||
after_context: str,
|
||||
page_number: int | None,
|
||||
chunk_index: int,
|
||||
total_chunks: int,
|
||||
has_before_truncation: bool,
|
||||
has_after_truncation: bool,
|
||||
) -> str:
|
||||
"""Insert position markers around matched chunk.
|
||||
|
||||
Creates markdown-formatted text with visual markers indicating chunk
|
||||
boundaries and metadata.
|
||||
|
||||
Args:
|
||||
before_context: Text before chunk
|
||||
chunk_text: The matched chunk
|
||||
after_context: Text after chunk
|
||||
page_number: Optional page number
|
||||
chunk_index: Zero-based chunk index
|
||||
total_chunks: Total chunks in document
|
||||
has_before_truncation: Whether before_context is truncated
|
||||
has_after_truncation: Whether after_context is truncated
|
||||
|
||||
Returns:
|
||||
Formatted text with position markers
|
||||
"""
|
||||
# Build position metadata
|
||||
position_parts = []
|
||||
if page_number is not None:
|
||||
position_parts.append(f"Page {page_number}")
|
||||
position_parts.append(f"Chunk {chunk_index + 1} of {total_chunks}")
|
||||
position_metadata = ", ".join(position_parts)
|
||||
|
||||
# Build marked text
|
||||
parts = []
|
||||
|
||||
# Add truncation indicator for before context
|
||||
if has_before_truncation:
|
||||
parts.append("**[...]**\n\n")
|
||||
|
||||
# Add before context if present
|
||||
if before_context:
|
||||
parts.append(before_context)
|
||||
|
||||
# Add chunk start marker
|
||||
parts.append(f"\n\n🔍 **MATCHED CHUNK START** ({position_metadata})\n\n")
|
||||
|
||||
# Add chunk text
|
||||
parts.append(chunk_text)
|
||||
|
||||
# Add chunk end marker
|
||||
parts.append("\n\n🔍 **MATCHED CHUNK END**\n\n")
|
||||
|
||||
# Add after context if present
|
||||
if after_context:
|
||||
parts.append(after_context)
|
||||
|
||||
# Add truncation indicator for after context
|
||||
if has_after_truncation:
|
||||
parts.append("\n\n**[...]**")
|
||||
|
||||
return "".join(parts)
|
||||
@@ -0,0 +1,907 @@
|
||||
"""PDF chunk highlighting utilities for vector visualization.
|
||||
|
||||
This module provides utilities to generate highlighted page images showing
|
||||
matched chunks and their context from semantic search results.
|
||||
|
||||
The highlighting uses character offsets to precisely locate chunks within
|
||||
PDF documents, ensuring accurate highlighting even when text formatting
|
||||
varies between indexing and rendering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import pymupdf
|
||||
import pymupdf4llm
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PDFHighlighter:
|
||||
"""Generate highlighted page images from PDF chunks."""
|
||||
|
||||
# Color definitions (RGB, 0-1 range)
|
||||
COLORS = {
|
||||
"yellow": [1, 1, 0],
|
||||
"red": [1, 0, 0],
|
||||
"green": [0, 1, 0],
|
||||
"blue": [0, 0, 1],
|
||||
"orange": [1, 0.5, 0],
|
||||
"pink": [1, 0, 1],
|
||||
"gray": [0.7, 0.7, 0.7],
|
||||
"light_blue": [0.7, 0.9, 1.0],
|
||||
"light_green": [0.7, 1.0, 0.7],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Remove markdown formatting to improve search accuracy.
|
||||
|
||||
Args:
|
||||
text: Text with potential markdown formatting
|
||||
|
||||
Returns:
|
||||
Plain text with markdown removed
|
||||
"""
|
||||
# Remove bold/italic markers
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||||
|
||||
# Remove headers
|
||||
text = re.sub(r"^#+\s+", "", text, flags=re.MULTILINE)
|
||||
|
||||
# Remove inline code
|
||||
text = re.sub(r"`(.+?)`", r"\1", text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def extract_pdf_text_with_boundaries(
|
||||
pdf_doc: pymupdf.Document,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""Extract full document text with page boundary tracking.
|
||||
|
||||
Uses pymupdf4llm.to_markdown() for consistency with indexing.
|
||||
|
||||
IMPORTANT: Must use write_images=True to match PyMuPDFProcessor behavior!
|
||||
Even though we don't need the images, we need the image references in the
|
||||
markdown text to maintain consistent character offsets with indexing.
|
||||
|
||||
Args:
|
||||
pdf_doc: Open PyMuPDF document
|
||||
|
||||
Returns:
|
||||
Tuple of (full_text, page_boundaries) where page_boundaries is a list of:
|
||||
{"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
page_boundaries = []
|
||||
text_parts = []
|
||||
current_offset = 0
|
||||
|
||||
# Use temp directory for image output (images are discarded after extraction)
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_"))
|
||||
|
||||
for page_idx in range(pdf_doc.page_count):
|
||||
page_md = pymupdf4llm.to_markdown(
|
||||
pdf_doc,
|
||||
pages=[page_idx],
|
||||
write_images=True, # Must match indexing! Otherwise offsets misalign
|
||||
image_path=temp_dir,
|
||||
page_chunks=False,
|
||||
)
|
||||
|
||||
page_boundaries.append(
|
||||
{
|
||||
"page": page_idx + 1, # 1-indexed
|
||||
"start_offset": current_offset,
|
||||
"end_offset": current_offset + len(page_md),
|
||||
}
|
||||
)
|
||||
|
||||
text_parts.append(page_md)
|
||||
current_offset += len(page_md)
|
||||
|
||||
full_text = "".join(text_parts)
|
||||
|
||||
# Clean up temp directory and extracted images
|
||||
import shutil
|
||||
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp directory {temp_dir}: {e}")
|
||||
|
||||
return full_text, page_boundaries
|
||||
|
||||
@staticmethod
|
||||
def find_chunk_page(
|
||||
chunk_start_offset: int,
|
||||
chunk_end_offset: int,
|
||||
page_boundaries: list[dict],
|
||||
) -> Optional[dict]:
|
||||
"""Find which page contains the most of a given chunk.
|
||||
|
||||
Args:
|
||||
chunk_start_offset: Chunk start position in full document
|
||||
chunk_end_offset: Chunk end position in full document
|
||||
page_boundaries: Page boundary list from extract_pdf_text_with_boundaries()
|
||||
|
||||
Returns:
|
||||
Dict with keys: page_num, overlap_chars, page_relative_start, page_relative_end
|
||||
or None if chunk not found on any page
|
||||
"""
|
||||
chunk_pages = []
|
||||
|
||||
for boundary in page_boundaries:
|
||||
page_start = boundary["start_offset"]
|
||||
page_end = boundary["end_offset"]
|
||||
|
||||
# Check if chunk overlaps with this page
|
||||
if chunk_start_offset < page_end and chunk_end_offset > page_start:
|
||||
overlap_start = max(chunk_start_offset, page_start)
|
||||
overlap_end = min(chunk_end_offset, page_end)
|
||||
overlap_chars = overlap_end - overlap_start
|
||||
|
||||
chunk_pages.append(
|
||||
{
|
||||
"page_num": boundary["page"],
|
||||
"overlap_chars": overlap_chars,
|
||||
"page_relative_start": overlap_start - page_start,
|
||||
"page_relative_end": overlap_end - page_start,
|
||||
}
|
||||
)
|
||||
|
||||
if not chunk_pages:
|
||||
return None
|
||||
|
||||
# Return page with maximum overlap
|
||||
return max(chunk_pages, key=lambda p: p["overlap_chars"])
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk_by_word_positions(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
color: str = "yellow",
|
||||
search_region: tuple[float, float, float, float] | None = None,
|
||||
) -> int:
|
||||
"""Highlight chunk using word-position matching.
|
||||
|
||||
This method matches words from the chunk to their positions on the PDF page,
|
||||
avoiding text search mismatches between markdown-formatted text and raw PDF text.
|
||||
|
||||
Args:
|
||||
page: PyMuPDF page object
|
||||
chunk_text: Text to highlight (may contain markdown)
|
||||
color: Color name from COLORS dict
|
||||
search_region: Optional (x0, y0, x1, y1) bounding box to constrain search.
|
||||
If provided, only words within this region are considered.
|
||||
|
||||
Returns:
|
||||
Number of highlight rectangles added
|
||||
"""
|
||||
# Tokenize chunk into words (alphanumeric only, lowercase)
|
||||
chunk_words = re.findall(
|
||||
r"\w+", PDFHighlighter.strip_markdown(chunk_text).lower()
|
||||
)
|
||||
|
||||
if not chunk_words:
|
||||
logger.warning("No words found in chunk text")
|
||||
return 0
|
||||
|
||||
# Get all words from page with positions
|
||||
# Format: (x0, y0, x1, y1, "word", block_no, line_no, word_no)
|
||||
try:
|
||||
page_words = page.get_text("words")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract words from page: {e}")
|
||||
return 0
|
||||
|
||||
if not page_words:
|
||||
logger.warning("No words found on page")
|
||||
return 0
|
||||
|
||||
# Filter words by search region if provided
|
||||
if search_region:
|
||||
rx0, ry0, rx1, ry1 = search_region
|
||||
# Allow some tolerance (10 points) for words near region boundary
|
||||
tolerance = 10
|
||||
page_words = [
|
||||
w
|
||||
for w in page_words
|
||||
if (
|
||||
w[0] >= rx0 - tolerance
|
||||
and w[2] <= rx1 + tolerance
|
||||
and w[1] >= ry0 - tolerance
|
||||
and w[3] <= ry1 + tolerance
|
||||
)
|
||||
]
|
||||
logger.debug(
|
||||
f"Filtered to {len(page_words)} words in region "
|
||||
f"({rx0:.0f}, {ry0:.0f}, {rx1:.0f}, {ry1:.0f})"
|
||||
)
|
||||
|
||||
if not page_words:
|
||||
logger.warning("No words found in search region")
|
||||
return 0
|
||||
|
||||
# Find matching word sequence - use FIRST match, not longest
|
||||
# This ensures we highlight the actual chunk location, not similar text elsewhere
|
||||
matches = []
|
||||
|
||||
# Build a simple word-to-positions index for the first few chunk words
|
||||
# to find candidate starting positions
|
||||
first_chunk_word = chunk_words[0] if chunk_words else ""
|
||||
candidate_starts = []
|
||||
|
||||
for i, pw in enumerate(page_words):
|
||||
page_word = pw[4].lower()
|
||||
# Check if this could be the start of the chunk
|
||||
if (
|
||||
first_chunk_word == page_word
|
||||
or first_chunk_word in page_word
|
||||
or page_word in first_chunk_word
|
||||
):
|
||||
candidate_starts.append(i)
|
||||
|
||||
# Try each candidate start position and take the FIRST good match
|
||||
for start_pos in candidate_starts:
|
||||
current_matches = []
|
||||
chunk_idx = 0
|
||||
skip_count = 0
|
||||
max_skips = 3 # Allow some formatting differences
|
||||
|
||||
for page_idx in range(start_pos, len(page_words)):
|
||||
if chunk_idx >= len(chunk_words):
|
||||
break
|
||||
|
||||
page_word = page_words[page_idx][4].lower()
|
||||
chunk_word = chunk_words[chunk_idx]
|
||||
|
||||
# Check for match (allow partial matches for flexibility)
|
||||
if (
|
||||
chunk_word == page_word
|
||||
or chunk_word in page_word
|
||||
or page_word in chunk_word
|
||||
):
|
||||
current_matches.append(page_words[page_idx])
|
||||
chunk_idx += 1
|
||||
skip_count = 0
|
||||
elif skip_count < max_skips:
|
||||
# Allow skipping some words (formatting, punctuation)
|
||||
skip_count += 1
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
# Accept if we matched at least 50% of chunk words
|
||||
if len(current_matches) >= len(chunk_words) * 0.5:
|
||||
matches = current_matches
|
||||
logger.debug(
|
||||
f"Found match at position {start_pos}: "
|
||||
f"{len(matches)}/{len(chunk_words)} words"
|
||||
)
|
||||
break # Take FIRST match, not best/longest
|
||||
|
||||
if not matches:
|
||||
logger.debug(f"No word matches found (chunk has {len(chunk_words)} words)")
|
||||
return 0
|
||||
|
||||
logger.debug(
|
||||
f"Matched {len(matches)} words out of {len(chunk_words)} chunk words"
|
||||
)
|
||||
|
||||
# Build rectangles from matched words
|
||||
rects = [pymupdf.Rect(w[0], w[1], w[2], w[3]) for w in matches]
|
||||
|
||||
# Check if matches are contiguous (not scattered across the page)
|
||||
# Scattered matches indicate false positives from common words
|
||||
if len(rects) > 1:
|
||||
# Sort by vertical position then horizontal
|
||||
sorted_matches = sorted(matches, key=lambda w: (round(w[1]), w[0]))
|
||||
|
||||
# Check for large vertical gaps (more than ~2 lines apart)
|
||||
# A typical line height is 12-20 points
|
||||
max_line_gap = 50 # Points - allows for ~2-3 lines gap
|
||||
prev_y = sorted_matches[0][1]
|
||||
large_gaps = 0
|
||||
|
||||
for match in sorted_matches[1:]:
|
||||
y_gap = match[1] - prev_y
|
||||
if y_gap > max_line_gap:
|
||||
large_gaps += 1
|
||||
prev_y = match[1]
|
||||
|
||||
# If matches are scattered (many large gaps), reject this match
|
||||
# A chunk should be mostly contiguous text
|
||||
if large_gaps > len(matches) * 0.3: # More than 30% have gaps
|
||||
logger.debug(
|
||||
f"Rejecting scattered matches: {large_gaps} large gaps "
|
||||
f"out of {len(matches)} matches"
|
||||
)
|
||||
return 0
|
||||
|
||||
# Merge adjacent rectangles on the same line for cleaner highlighting
|
||||
merged_rects = []
|
||||
sorted_rects = sorted(rects, key=lambda r: (round(r.y0), r.x0))
|
||||
|
||||
current_rect = None
|
||||
for rect in sorted_rects:
|
||||
if current_rect is None:
|
||||
current_rect = rect
|
||||
elif abs(rect.y0 - current_rect.y0) < 5: # Same line (within 5 points)
|
||||
current_rect = current_rect | rect # Union
|
||||
else:
|
||||
merged_rects.append(current_rect)
|
||||
current_rect = rect
|
||||
|
||||
if current_rect:
|
||||
merged_rects.append(current_rect)
|
||||
|
||||
# Add highlights
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
for rect in merged_rects:
|
||||
highlight = page.add_highlight_annot(rect)
|
||||
highlight.set_colors({"stroke": rgb})
|
||||
highlight.set_info(
|
||||
content="Chunk from semantic search",
|
||||
title="PDF Highlighter (word-position)",
|
||||
)
|
||||
highlight.update()
|
||||
|
||||
return len(merged_rects)
|
||||
|
||||
@staticmethod
|
||||
def find_unique_phrase(
|
||||
text: str, min_len: int = 30, max_len: int = 80
|
||||
) -> str | None:
|
||||
"""Find a relatively unique phrase from text for location search.
|
||||
|
||||
Looks for phrases that are likely to be unique on the page:
|
||||
- Prefers phrases with numbers or special terms
|
||||
- Avoids very common words
|
||||
|
||||
Args:
|
||||
text: Source text to extract phrase from
|
||||
min_len: Minimum phrase length
|
||||
max_len: Maximum phrase length
|
||||
|
||||
Returns:
|
||||
A phrase likely to be unique, or None if not found
|
||||
"""
|
||||
clean_text = PDFHighlighter.strip_markdown(text).strip()
|
||||
if not clean_text:
|
||||
return None
|
||||
|
||||
# Try first sentence (often unique due to context)
|
||||
sentences = re.split(r"[.!?]\s+", clean_text)
|
||||
for sentence in sentences:
|
||||
sentence = sentence.strip()
|
||||
if min_len <= len(sentence) <= max_len:
|
||||
return sentence
|
||||
elif len(sentence) > max_len:
|
||||
return sentence[:max_len]
|
||||
|
||||
# Fallback: first N chars
|
||||
if len(clean_text) >= min_len:
|
||||
return clean_text[:max_len]
|
||||
|
||||
return clean_text if clean_text else None
|
||||
|
||||
@staticmethod
|
||||
def _find_chunk_bbox(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
page_relative_start: int,
|
||||
page_relative_end: int,
|
||||
page_text_length: int,
|
||||
) -> tuple[float, float, float, float] | None:
|
||||
"""Find bounding box for a chunk without modifying the page.
|
||||
|
||||
Returns (x0, y0, x1, y1) in page coordinates, or None if not found.
|
||||
"""
|
||||
page_rect = page.rect
|
||||
|
||||
# Strip markdown for searching
|
||||
search_text = PDFHighlighter.strip_markdown(chunk_text)
|
||||
|
||||
# Try to find chunk location using text search
|
||||
anchor_rect = None
|
||||
search_phrases = []
|
||||
|
||||
# Build search phrases from chunk text
|
||||
sentences = re.split(r"[.!?]\s+", search_text)
|
||||
for sentence in sentences[:3]:
|
||||
sentence = sentence.strip()
|
||||
if len(sentence) >= 20:
|
||||
search_phrases.append(sentence[:80])
|
||||
if len(sentence) >= 40:
|
||||
search_phrases.append(sentence[:40])
|
||||
|
||||
# Also try first N characters
|
||||
if len(search_text) >= 30:
|
||||
search_phrases.append(search_text[:60])
|
||||
search_phrases.append(search_text[:30])
|
||||
|
||||
for phrase in search_phrases:
|
||||
if not phrase:
|
||||
continue
|
||||
rects = page.search_for(phrase.strip())
|
||||
if rects:
|
||||
anchor_rect = rects[0]
|
||||
break
|
||||
|
||||
if not anchor_rect:
|
||||
return None
|
||||
|
||||
# Calculate chunk height based on character count
|
||||
chunk_chars = len(search_text)
|
||||
estimated_lines = max(1, chunk_chars / 60)
|
||||
estimated_height = estimated_lines * 14
|
||||
|
||||
# Build bounding box
|
||||
return (
|
||||
page_rect.x0 + 30, # Left margin
|
||||
anchor_rect.y0 - 5, # Start slightly above anchor
|
||||
page_rect.x1 - 30, # Right margin
|
||||
min(anchor_rect.y0 + estimated_height + 10, page_rect.y1 - 30),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk_on_page(
|
||||
page: pymupdf.Page,
|
||||
chunk_text: str,
|
||||
color: str = "yellow",
|
||||
page_relative_start: int | None = None,
|
||||
page_relative_end: int | None = None,
|
||||
page_text_length: int | None = None,
|
||||
) -> int:
|
||||
"""Add bounding box highlight to a PDF page for the given chunk text.
|
||||
|
||||
Uses text search to find the chunk's location on the page, then draws
|
||||
a bounding box around that region. Falls back to character offset estimation
|
||||
if text search fails.
|
||||
|
||||
Args:
|
||||
page: PyMuPDF page object
|
||||
chunk_text: Text to highlight (may contain markdown)
|
||||
color: Color name from COLORS dict
|
||||
page_relative_start: Character offset where chunk starts on page (optional)
|
||||
page_relative_end: Character offset where chunk ends on page (optional)
|
||||
page_text_length: Total character length of page text (optional)
|
||||
|
||||
Returns:
|
||||
Number of highlights added (1 for bounding box, 0 if failed)
|
||||
"""
|
||||
page_rect = page.rect
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
|
||||
# Strip markdown for searching
|
||||
search_text = PDFHighlighter.strip_markdown(chunk_text)
|
||||
|
||||
# Try to find chunk location using text search
|
||||
# Search for progressively shorter phrases until we find a match
|
||||
anchor_rect = None
|
||||
search_phrases = []
|
||||
|
||||
# Build search phrases from chunk text
|
||||
sentences = re.split(r"[.!?]\s+", search_text)
|
||||
for sentence in sentences[:3]: # Try first 3 sentences
|
||||
sentence = sentence.strip()
|
||||
if len(sentence) >= 20:
|
||||
search_phrases.append(sentence[:80])
|
||||
if len(sentence) >= 40:
|
||||
search_phrases.append(sentence[:40])
|
||||
|
||||
# Also try first N characters
|
||||
if len(search_text) >= 30:
|
||||
search_phrases.append(search_text[:60])
|
||||
search_phrases.append(search_text[:30])
|
||||
|
||||
for phrase in search_phrases:
|
||||
if not phrase:
|
||||
continue
|
||||
rects = page.search_for(phrase.strip())
|
||||
if rects:
|
||||
anchor_rect = rects[0] # Use first match
|
||||
logger.debug(f"Found chunk anchor using phrase: '{phrase[:30]}...'")
|
||||
break
|
||||
|
||||
if not anchor_rect:
|
||||
page_num = page.number + 1 if page.number is not None else "unknown"
|
||||
logger.warning(f"Could not find chunk text on page {page_num}")
|
||||
return 0
|
||||
|
||||
# Calculate chunk height based on character count
|
||||
# Estimate ~15 chars per line, ~12pt line height
|
||||
chunk_chars = len(search_text)
|
||||
estimated_lines = max(1, chunk_chars / 60) # ~60 chars per line typical
|
||||
estimated_height = estimated_lines * 14 # ~14pt per line
|
||||
|
||||
# Build bounding box starting from anchor
|
||||
chunk_rect = pymupdf.Rect(
|
||||
page_rect.x0 + 30, # Left margin
|
||||
anchor_rect.y0 - 5, # Start slightly above anchor
|
||||
page_rect.x1 - 30, # Right margin
|
||||
min(
|
||||
anchor_rect.y0 + estimated_height + 10, page_rect.y1 - 30
|
||||
), # Estimated bottom
|
||||
)
|
||||
|
||||
# Draw a visible rectangle around the chunk region
|
||||
shape = page.new_shape()
|
||||
shape.draw_rect(chunk_rect)
|
||||
shape.finish(
|
||||
color=rgb, # Border color
|
||||
fill=None, # No fill (transparent)
|
||||
width=2.5, # Border width
|
||||
dashes="[4 2]", # Dashed line
|
||||
)
|
||||
shape.commit()
|
||||
|
||||
# Add semi-transparent fill for visibility
|
||||
fill_shape = page.new_shape()
|
||||
fill_shape.draw_rect(chunk_rect)
|
||||
fill_shape.finish(
|
||||
color=None, # No border
|
||||
fill=[1, 1, 0.7], # Light yellow fill
|
||||
fill_opacity=0.15, # Very transparent
|
||||
)
|
||||
fill_shape.commit()
|
||||
|
||||
logger.debug(
|
||||
f"Added bounding box at y={chunk_rect.y0:.0f}-{chunk_rect.y1:.0f} "
|
||||
f"(estimated {estimated_lines:.1f} lines)"
|
||||
)
|
||||
|
||||
return 1
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunk(
|
||||
pdf_bytes: bytes,
|
||||
chunk_start_offset: int,
|
||||
chunk_end_offset: int,
|
||||
stored_page_number: Optional[int] = None,
|
||||
color: str = "yellow",
|
||||
zoom: float = 2.0,
|
||||
) -> Optional[tuple[bytes, int, int]]:
|
||||
"""Generate PNG image of PDF page with highlighted chunk.
|
||||
|
||||
This is the main entry point for highlighting. It:
|
||||
1. Extracts document text with page boundaries
|
||||
2. Finds which page contains the chunk
|
||||
3. Extracts chunk text using character offsets
|
||||
4. Highlights the chunk on the page
|
||||
5. Renders page to PNG
|
||||
|
||||
Args:
|
||||
pdf_bytes: PDF file bytes
|
||||
chunk_start_offset: Chunk start position (document-level)
|
||||
chunk_end_offset: Chunk end position (document-level)
|
||||
stored_page_number: Page number from metadata (optional, for validation)
|
||||
color: Highlight color name
|
||||
zoom: Rendering zoom factor (2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
Tuple of (png_bytes, page_number, highlight_count) or None if failed
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
temp_pdf_path = None
|
||||
try:
|
||||
# Write PDF to temp file with consistent name "pdf.pdf"
|
||||
# This ensures image references match indexing (e.g., pdf-0001.png)
|
||||
# Different temp filenames would cause different markdown text lengths!
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_"))
|
||||
temp_pdf_path = temp_dir / "pdf.pdf"
|
||||
temp_pdf_path.write_bytes(pdf_bytes)
|
||||
|
||||
# Open PDF from temp file
|
||||
doc = pymupdf.open(temp_pdf_path)
|
||||
|
||||
# Extract text with page boundaries
|
||||
full_text, page_boundaries = (
|
||||
PDFHighlighter.extract_pdf_text_with_boundaries(doc)
|
||||
)
|
||||
|
||||
# Find which page contains the chunk
|
||||
chunk_page_info = PDFHighlighter.find_chunk_page(
|
||||
chunk_start_offset, chunk_end_offset, page_boundaries
|
||||
)
|
||||
|
||||
if not chunk_page_info:
|
||||
logger.error("Chunk not found on any page")
|
||||
doc.close()
|
||||
return None
|
||||
|
||||
page_num = chunk_page_info["page_num"]
|
||||
|
||||
# Log if page differs from stored metadata
|
||||
if stored_page_number and stored_page_number != page_num:
|
||||
logger.info(
|
||||
f"Chunk primarily on page {page_num}, metadata says {stored_page_number}"
|
||||
)
|
||||
|
||||
# Extract page text
|
||||
page_boundary = page_boundaries[page_num - 1]
|
||||
page_start = page_boundary["start_offset"]
|
||||
page_end = page_boundary["end_offset"]
|
||||
page_text = full_text[page_start:page_end]
|
||||
|
||||
# Extract chunk text using page-relative offsets
|
||||
page_relative_start = chunk_page_info["page_relative_start"]
|
||||
page_relative_end = chunk_page_info["page_relative_end"]
|
||||
chunk_text = page_text[page_relative_start:page_relative_end]
|
||||
|
||||
# Calculate page text length for region estimation
|
||||
page_text_length = page_end - page_start
|
||||
|
||||
logger.debug(
|
||||
f"Extracted {len(chunk_text)} chars on page {page_num} "
|
||||
f"(offsets {page_relative_start}-{page_relative_end} of {page_text_length})"
|
||||
)
|
||||
|
||||
# Get page and add highlights
|
||||
page = doc[page_num - 1]
|
||||
highlight_count = PDFHighlighter.highlight_chunk_on_page(
|
||||
page,
|
||||
chunk_text,
|
||||
color,
|
||||
page_relative_start=page_relative_start,
|
||||
page_relative_end=page_relative_end,
|
||||
page_text_length=page_text_length,
|
||||
)
|
||||
|
||||
if highlight_count == 0:
|
||||
logger.warning("No highlights added")
|
||||
doc.close()
|
||||
return None
|
||||
|
||||
# Render page to PNG
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
png_bytes = pix.tobytes("png")
|
||||
|
||||
doc.close()
|
||||
|
||||
logger.info(
|
||||
f"Generated {len(png_bytes):,} byte image with {highlight_count} highlights"
|
||||
)
|
||||
|
||||
return (png_bytes, page_num, highlight_count)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error highlighting chunk: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
finally:
|
||||
# Clean up temp directory and PDF file
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete temp directory {temp_pdf_path.parent}: {e}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def highlight_chunks_batch(
|
||||
pdf_bytes: bytes,
|
||||
chunks: list[tuple[int, int, int, int | None, str]],
|
||||
page_boundaries: list[dict],
|
||||
full_text: str,
|
||||
color: str = "yellow",
|
||||
zoom: float = 2.0,
|
||||
) -> dict[int, tuple[bytes, int, int]]:
|
||||
"""Generate highlighted images for multiple chunks.
|
||||
|
||||
Opens PDF once for rendering, uses pre-computed page boundaries from the
|
||||
document processor. This ensures consistent character offsets between
|
||||
chunking and highlighting.
|
||||
|
||||
Args:
|
||||
pdf_bytes: PDF file bytes
|
||||
chunks: List of (chunk_index, start_offset, end_offset, stored_page_number, chunk_text)
|
||||
The chunk_index is used as the key in the returned dict.
|
||||
chunk_text is the actual text content of the chunk.
|
||||
page_boundaries: Pre-computed page boundaries from document processor.
|
||||
Each entry: {"page": 1, "start_offset": 0, "end_offset": 1234}
|
||||
full_text: Full document text for extracting page-relative portions.
|
||||
color: Highlight color name
|
||||
zoom: Rendering zoom factor (2.0 = 144 DPI)
|
||||
|
||||
Returns:
|
||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||
Chunks that fail to highlight are omitted from the result.
|
||||
"""
|
||||
import shutil
|
||||
import tempfile
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
results: dict[int, tuple[bytes, int, int]] = {}
|
||||
|
||||
if not chunks:
|
||||
return results
|
||||
|
||||
temp_pdf_path = None
|
||||
try:
|
||||
# Write PDF to temp file
|
||||
temp_dir = Path(tempfile.mkdtemp(prefix="pdf_highlight_batch_"))
|
||||
temp_pdf_path = temp_dir / "pdf.pdf"
|
||||
temp_pdf_path.write_bytes(pdf_bytes)
|
||||
|
||||
# Open PDF once (only for rendering, not text extraction)
|
||||
doc = pymupdf.open(temp_pdf_path)
|
||||
|
||||
logger.debug(
|
||||
f"Batch highlighting: {len(chunks)} chunks, "
|
||||
f"{len(page_boundaries)} pages"
|
||||
)
|
||||
|
||||
# Group chunks by their target page for efficient rendering
|
||||
# We'll render each page only once with all its highlights
|
||||
chunks_by_page: dict[int, list[tuple[int, dict, str]]] = defaultdict(list)
|
||||
|
||||
for chunk_tuple in chunks:
|
||||
# Unpack chunk tuple - chunk_text is now passed directly
|
||||
chunk_index, start_offset, end_offset, stored_page_num, chunk_text = (
|
||||
chunk_tuple
|
||||
)
|
||||
|
||||
# Find which page contains this chunk
|
||||
chunk_page_info = PDFHighlighter.find_chunk_page(
|
||||
start_offset, end_offset, page_boundaries
|
||||
)
|
||||
|
||||
if not chunk_page_info:
|
||||
logger.warning(f"Chunk {chunk_index}: not found on any page")
|
||||
continue
|
||||
|
||||
page_num = chunk_page_info["page_num"]
|
||||
|
||||
# Log if page differs from stored metadata
|
||||
if stored_page_num and stored_page_num != page_num:
|
||||
logger.debug(
|
||||
f"Chunk {chunk_index}: found on page {page_num}, "
|
||||
f"metadata says {stored_page_num}"
|
||||
)
|
||||
|
||||
# Extract page-relative portion of chunk text
|
||||
# This is critical for cross-page chunks where the start
|
||||
# of the chunk might be on a different page
|
||||
page_boundary = page_boundaries[page_num - 1]
|
||||
page_start = page_boundary["start_offset"]
|
||||
page_end = page_boundary["end_offset"]
|
||||
page_text_length = page_end - page_start
|
||||
|
||||
# Calculate what portion of the chunk appears on this page
|
||||
chunk_start_on_page = max(start_offset, page_start)
|
||||
chunk_end_on_page = min(end_offset, page_end)
|
||||
|
||||
# Extract just the text that appears on this page
|
||||
page_relative_text = full_text[chunk_start_on_page:chunk_end_on_page]
|
||||
|
||||
chunks_by_page[page_num].append(
|
||||
(chunk_index, chunk_page_info, page_relative_text, page_text_length)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Chunks distributed across {len(chunks_by_page)} unique pages"
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||
# This avoids expensive page.get_pixmap() calls per chunk
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# PIL color for bounding box (RGB tuple)
|
||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||
pil_color = tuple(int(c * 255) for c in rgb)
|
||||
fill_color = (255, 255, 178, 38) # Light yellow with alpha
|
||||
|
||||
for page_num, page_chunks in chunks_by_page.items():
|
||||
page = doc[page_num - 1]
|
||||
|
||||
# Render page ONCE to get base image (most expensive operation)
|
||||
mat = pymupdf.Matrix(zoom, zoom)
|
||||
base_pix = page.get_pixmap(matrix=mat, alpha=False)
|
||||
base_png = base_pix.tobytes("png")
|
||||
|
||||
# Convert to PIL Image for fast highlight drawing
|
||||
base_image = Image.open(BytesIO(base_png)).convert("RGBA")
|
||||
page_rect = page.rect
|
||||
|
||||
logger.debug(
|
||||
f"Page {page_num}: rendered once, processing {len(page_chunks)} chunks"
|
||||
)
|
||||
|
||||
for (
|
||||
chunk_index,
|
||||
chunk_page_info,
|
||||
chunk_text,
|
||||
page_text_length,
|
||||
) in page_chunks:
|
||||
try:
|
||||
# Find chunk bounding box using text search
|
||||
bbox = PDFHighlighter._find_chunk_bbox(
|
||||
page,
|
||||
chunk_text,
|
||||
chunk_page_info["page_relative_start"],
|
||||
chunk_page_info["page_relative_end"],
|
||||
page_text_length,
|
||||
)
|
||||
|
||||
if bbox is None:
|
||||
logger.warning(f"Chunk {chunk_index}: could not find bbox")
|
||||
continue
|
||||
|
||||
# Copy base image for this chunk
|
||||
chunk_image = base_image.copy()
|
||||
|
||||
# Scale bbox coordinates to pixmap coordinates
|
||||
scale_x = base_pix.width / page_rect.width
|
||||
scale_y = base_pix.height / page_rect.height
|
||||
pil_bbox = (
|
||||
int(bbox[0] * scale_x),
|
||||
int(bbox[1] * scale_y),
|
||||
int(bbox[2] * scale_x),
|
||||
int(bbox[3] * scale_y),
|
||||
)
|
||||
|
||||
# Create transparent overlay for fill (proper alpha blending)
|
||||
overlay = Image.new("RGBA", chunk_image.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(pil_bbox, fill=fill_color)
|
||||
|
||||
# Alpha composite the overlay onto the chunk image
|
||||
chunk_image = Image.alpha_composite(chunk_image, overlay)
|
||||
|
||||
# Draw border on top (solid, not transparent)
|
||||
border_draw = ImageDraw.Draw(chunk_image)
|
||||
border_draw.rectangle(pil_bbox, outline=pil_color, width=3)
|
||||
|
||||
# Convert back to PNG bytes
|
||||
output = BytesIO()
|
||||
chunk_image.convert("RGB").save(output, format="PNG")
|
||||
png_bytes = output.getvalue()
|
||||
|
||||
results[chunk_index] = (png_bytes, page_num, 1)
|
||||
|
||||
logger.debug(
|
||||
f"Chunk {chunk_index}: {len(png_bytes):,} bytes, "
|
||||
f"page {page_num}, bbox {pil_bbox}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Chunk {chunk_index}: error - {e}")
|
||||
continue
|
||||
|
||||
doc.close()
|
||||
|
||||
logger.info(
|
||||
f"Batch highlighted {len(results)}/{len(chunks)} chunks successfully"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in batch highlighting: {e}", exc_info=True)
|
||||
return results
|
||||
|
||||
finally:
|
||||
# Clean up temp directory
|
||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||
try:
|
||||
shutil.rmtree(temp_pdf_path.parent)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to clean up temp dir: {e}")
|
||||
@@ -9,6 +9,7 @@ from nextcloud_mcp_server.config import get_settings
|
||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
|
||||
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
|
||||
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__)
|
||||
@@ -50,6 +51,9 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
Returns unverified results from Qdrant. Access verification should be
|
||||
performed separately at the final output stage using verify_search_results().
|
||||
|
||||
Deduplicates by (doc_id, doc_type, chunk_start_offset, chunk_end_offset)
|
||||
to show multiple chunks from the same document while avoiding duplicate chunks.
|
||||
|
||||
Args:
|
||||
query: Natural language search query
|
||||
user_id: User ID for filtering
|
||||
@@ -74,16 +78,19 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
# Generate embedding for query
|
||||
embedding_service = get_embedding_service()
|
||||
query_embedding = await embedding_service.embed(query)
|
||||
# Store for reuse by callers (e.g., viz_routes PCA visualization)
|
||||
self.query_embedding = query_embedding
|
||||
logger.debug(
|
||||
f"Generated embedding for query (dimension={len(query_embedding)})"
|
||||
)
|
||||
|
||||
# Build Qdrant filter
|
||||
filter_conditions = [
|
||||
get_placeholder_filter(), # Always exclude placeholders from user-facing queries
|
||||
FieldCondition(
|
||||
key="user_id",
|
||||
match=MatchValue(value=user_id),
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
# Add doc_type filter if specified
|
||||
@@ -123,20 +130,41 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
top_scores = [p.score for p in search_response.points[:3]]
|
||||
logger.debug(f"Top 3 similarity scores: {top_scores}")
|
||||
|
||||
# Deduplicate by (doc_id, doc_type) - multiple chunks per document
|
||||
seen_docs = set()
|
||||
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
|
||||
# This allows multiple chunks from same doc, but removes duplicate chunks
|
||||
seen_chunks = set()
|
||||
results = []
|
||||
|
||||
for result in search_response.points:
|
||||
doc_id = int(result.payload["doc_id"])
|
||||
if result.payload is None:
|
||||
continue
|
||||
# doc_id can be int (notes) or str (files - file paths)
|
||||
doc_id = result.payload["doc_id"]
|
||||
doc_type = result.payload.get("doc_type", "note")
|
||||
doc_key = (doc_id, doc_type)
|
||||
chunk_start = result.payload.get("chunk_start_offset")
|
||||
chunk_end = result.payload.get("chunk_end_offset")
|
||||
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
|
||||
|
||||
# Skip if we've already seen this document
|
||||
if doc_key in seen_docs:
|
||||
# Skip if we've already seen this exact chunk
|
||||
if chunk_key in seen_chunks:
|
||||
continue
|
||||
|
||||
seen_docs.add(doc_key)
|
||||
seen_chunks.add(chunk_key)
|
||||
|
||||
# Build metadata dict with common fields
|
||||
metadata = {
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
}
|
||||
|
||||
# Add file-specific metadata for PDF viewer
|
||||
if doc_type == "file" and (path := result.payload.get("file_path")):
|
||||
metadata["path"] = path
|
||||
|
||||
# Add deck_card-specific metadata for frontend URL construction
|
||||
if doc_type == "deck_card":
|
||||
if board_id := result.payload.get("board_id"):
|
||||
metadata["board_id"] = board_id
|
||||
|
||||
# Return unverified results (verification happens at output stage)
|
||||
results.append(
|
||||
@@ -146,10 +174,14 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
||||
title=result.payload.get("title", "Untitled"),
|
||||
excerpt=result.payload.get("excerpt", ""),
|
||||
score=result.score,
|
||||
metadata={
|
||||
"chunk_index": result.payload.get("chunk_index"),
|
||||
"total_chunks": result.payload.get("total_chunks"),
|
||||
},
|
||||
metadata=metadata,
|
||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||
page_number=result.payload.get("page_number"),
|
||||
page_count=result.payload.get("page_count"),
|
||||
chunk_index=result.payload.get("chunk_index", 0),
|
||||
total_chunks=result.payload.get("total_chunks", 1),
|
||||
point_id=str(result.id), # Qdrant point ID for batch retrieval
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
||||
from .contacts import configure_contacts_tools
|
||||
from .cookbook import configure_cookbook_tools
|
||||
from .deck import configure_deck_tools
|
||||
from .news import configure_news_tools
|
||||
from .notes import configure_notes_tools
|
||||
from .semantic import configure_semantic_tools
|
||||
from .sharing import configure_sharing_tools
|
||||
@@ -13,6 +14,7 @@ __all__ = [
|
||||
"configure_contacts_tools",
|
||||
"configure_cookbook_tools",
|
||||
"configure_deck_tools",
|
||||
"configure_news_tools",
|
||||
"configure_notes_tools",
|
||||
"configure_semantic_tools",
|
||||
"configure_sharing_tools",
|
||||
|
||||
@@ -3,6 +3,7 @@ import logging
|
||||
from typing import Optional
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -19,7 +20,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_calendar_tools(mcp: FastMCP):
|
||||
# Calendar tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Calendars",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||
@@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Calendar Event",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_event(
|
||||
@@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Calendar Events",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_events(
|
||||
@@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return events
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Calendar Event",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_get_event(
|
||||
@@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||
return event_data
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Calendar Event",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_update_event(
|
||||
@@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
calendar_name, event_uid, event_data, etag
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Calendar Event",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_delete_event(
|
||||
@@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Meeting",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_meeting(
|
||||
@@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_event(calendar_name, event_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Get Upcoming Events",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_get_upcoming_events(
|
||||
@@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||
return all_events[:limit]
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Find Availability",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_find_availability(
|
||||
@@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
constraints=constraints,
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Bulk Calendar Operations",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_bulk_operations(
|
||||
@@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
"results": results,
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Manage Calendar",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("calendar:write")
|
||||
@instrument_tool
|
||||
async def nc_calendar_manage_calendar(
|
||||
@@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
# ============= Todo/Task Tools =============
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Todo Tasks",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_list_todos(
|
||||
@@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Todo Task",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_create_todo(
|
||||
@@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Todo Task",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_update_todo(
|
||||
@@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
|
||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Todo Task",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("todo:write", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_delete_todo(
|
||||
@@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Search Todo Tasks",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("todo:read", "calendar:read")
|
||||
@instrument_tool
|
||||
async def nc_calendar_search_todos(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import Context, FastMCP
|
||||
from mcp.types import ToolAnnotations
|
||||
|
||||
from nextcloud_mcp_server.auth import require_scopes
|
||||
from nextcloud_mcp_server.context import get_client
|
||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def configure_contacts_tools(mcp: FastMCP):
|
||||
# Contacts tools
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Address Books",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||
@@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_addressbooks()
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="List Contacts",
|
||||
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:read")
|
||||
@instrument_tool
|
||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||
@@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Address Book",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_create_addressbook(
|
||||
@@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
name=name, display_name=display_name
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Address Book",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||
@@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_addressbook(name=name)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Create Contact",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_create_contact(
|
||||
@@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||
)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Delete Contact",
|
||||
annotations=ToolAnnotations(
|
||||
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||
),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||
@@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(
|
||||
title="Update Contact",
|
||||
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||
)
|
||||
@require_scopes("contacts:write")
|
||||
@instrument_tool
|
||||
async def nc_contacts_update_contact(
|
||||
|
||||