Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot-cbcoutinho[bot] ac116366e9 chore(deps): update dependency python to 3.14 2025-12-20 11:13:15 +00:00
254 changed files with 31738 additions and 24269 deletions
@@ -0,0 +1,89 @@
name: Build and Publish Astrolabe App Release
on:
push:
tags:
- 'astrolabe-v*'
env:
APP_NAME: astrolabe
APP_DIR: third_party/astrolabe
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@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.4
with:
app_name: ${{ env.APP_NAME }}
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
+275
View File
@@ -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"
+39 -45
View File
@@ -15,15 +15,15 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.14'
- name: Install uv
run: |
@@ -71,7 +71,7 @@ jobs:
fi
}
# Bump MCP server (default - all commits except helm scope)
# Bump MCP server (default - all commits except helm/astrolabe scopes)
echo "Checking MCP server for version bump..."
# Get the most recent MCP tag
@@ -83,36 +83,33 @@ jobs:
commit_range="${last_mcp_tag}..HEAD"
fi
# Count conventional commits that are NOT scoped to helm
# Count conventional commits that are NOT scoped to helm or astrolabe
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
{ grep -v "(helm)" || true; } | wc -l)
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
MCP_BUMPED=false
if [ "$mcp_commit_count" -gt 0 ]; then
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
echo "Bumping MCP server version..."
./scripts/bump-mcp.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
MCP_BUMPED=true
else
echo "No commits found for MCP server since $last_mcp_tag"
fi
# Bump Helm chart (scope: helm OR when MCP appVersion changes)
# Bump Helm chart (scope: helm)
echo "Checking Helm chart for version bump..."
HELM_HAS_COMMITS=false
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
HELM_HAS_COMMITS=true
fi
if [ "$HELM_HAS_COMMITS" = true ]; then
echo "Bumping Helm chart version (helm-scoped commits)..."
echo "Bumping Helm chart version..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
elif [ "$MCP_BUMPED" = true ]; then
echo "Bumping Helm chart version (appVersion changed)..."
./scripts/bump-helm.sh --increment PATCH
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
fi
# Bump Astrolabe (scope: astrolabe)
echo "Checking Astrolabe for version bump..."
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
echo "Bumping Astrolabe version..."
./scripts/bump-astrolabe.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
fi
# Output summary
@@ -133,32 +130,29 @@ jobs:
echo "Pushed tags for components:${{ steps.bump.outputs.components }}"
- name: Summary
if: steps.bump.outputs.bumped == 'true'
run: |
if [ "${{ steps.bump.outputs.bumped }}" == "true" ]; then
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
astrolabe)
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
else
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "✅ No version bumps required - no relevant commits found since last release." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The workflow completed successfully with no changes." >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
+2 -3
View File
@@ -27,16 +27,15 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
allowed_bots: "renovate-bot-cbcoutinho"
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
+2 -2
View File
@@ -26,13 +26,13 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@cd77b50d2b0808657f8e6774085c8bf54484351c # v1.0.72
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+5 -5
View File
@@ -13,11 +13,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
with:
# list of Docker images to use as base name for tags
images: |
@@ -34,18 +34,18 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
with:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
+1 -2
View File
@@ -4,7 +4,6 @@ on:
push:
tags:
- v*
- nextcloud-mcp-server-*
jobs:
release:
@@ -15,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
+4 -4
View File
@@ -24,10 +24,10 @@ jobs:
models: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
with:
compose-file: |
./docker-compose.yml
@@ -42,7 +42,7 @@ jobs:
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Wait for Nextcloud to be ready
run: |
@@ -97,7 +97,7 @@ jobs:
- name: Upload test results
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
with:
name: rag-evaluation-results
path: |
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+38 -163
View File
@@ -1,4 +1,4 @@
name: Tests
name: Docker Compose Action
on:
pull_request:
@@ -9,205 +9,80 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Check format
run: uv run --frozen ruff format --diff
run: |
uv run --frozen ruff format --diff
- name: Linting
run: uv run --frozen ruff check
- name: Type check
run: uv run --frozen ty check -- nextcloud_mcp_server
run: |
uv run --frozen ruff check
- name: Linting
run: |
uv run --frozen ty check -- nextcloud_mcp_server
unit-test:
runs-on: ubuntu-latest
needs: [linting]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Run unit tests
run: uv run pytest -v -m unit -o "addopts=-p no:asyncio"
integration-test:
runs-on: ubuntu-latest
needs: [linting]
strategy:
fail-fast: false
matrix:
nextcloud_version:
- "31"
- "32"
# - "33" # Disabled until all upstream apps support NC 33
mode:
- "single-user"
- "multi-user-basic"
- "oauth"
- "login-flow"
include:
# Version-specific image pins — Renovate updates these via customManagers
# renovate: datasource=docker depName=docker.io/library/nextcloud
- nextcloud_version: "31"
nextcloud_image: "docker.io/library/nextcloud:31.0.14@sha256:9bf3fae91aad4dca3eff02c1f71df8d5c6705a349065fb537aa5c5ef578f1013"
# renovate: datasource=docker depName=docker.io/library/nextcloud
- nextcloud_version: "32"
nextcloud_image: "docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267"
# renovate: datasource=docker depName=docker.io/library/nextcloud
# Disabled until all upstream apps support NC 33
# - nextcloud_version: "33"
# nextcloud_image: "docker.io/library/nextcloud:33.0.0@sha256:d53f6cb35b0712aa890a5e4a8ca21043d6fcd390f38c55b710816dd7cbc2edc0"
# Mode-specific properties
- mode: single-user
profile: single-user
markers: "(smoke and not oauth and not keycloak and not login_flow and not multi_user_basic) or (integration and not oauth and not keycloak and not login_flow and not multi_user_basic)"
wait-port: 8000
mcp-internal-url: "http://mcp:8000"
needs-playwright: false
extra-args: >-
--ignore=tests/integration/test_qdrant_collection_creation.py
--ignore=tests/rag_evaluation/
- mode: multi-user-basic
profile: multi-user-basic
markers: "multi_user_basic"
wait-port: 8003
mcp-internal-url: "http://mcp-multi-user-basic:8000"
needs-playwright: true
extra-args: ""
- mode: oauth
profile: oauth
markers: "oauth and not keycloak"
wait-port: 8001
mcp-internal-url: "http://mcp-oauth:8001"
needs-playwright: true
extra-args: ""
- mode: login-flow
profile: login-flow
markers: "login_flow"
wait-port: 8004
mcp-internal-url: "http://mcp-login-flow:8004"
needs-playwright: true
extra-args: ""
name: integration (${{ matrix.mode }} / nc${{ matrix.nextcloud_version }})
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
submodules: 'true'
- name: Set up PHP 8.4
if: matrix.mode != 'single-user'
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
with:
php-version: 8.4
coverage: none
# OIDC app installed from app store (dev mount removed from docker-compose.yml)
- name: Set up Node.js
if: matrix.mode != 'single-user'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 24
- name: Build Astrolabe app
if: matrix.mode != 'single-user'
- name: Install OIDC app composer dependencies
run: |
cd third_party/astrolabe
composer install --no-dev --optimize-autoloader
npm ci
npm run build
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
# Start services with the appropriate profile
- name: Run docker compose
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
with:
compose-file: "./docker-compose.yml"
compose-flags: "--profile ${{ matrix.profile }}"
#compose-flags: "--profile qdrant"
up-flags: "--build"
env:
MCP_SERVER_URL: ${{ matrix.mcp-internal-url }}
NEXTCLOUD_IMAGE: ${{ matrix.nextcloud_image }}
- name: Install the latest version of uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
- name: Install Playwright
if: matrix.needs-playwright
run: uv run playwright install chromium --with-deps
# Wait for Nextcloud to be healthy
- name: Wait for Nextcloud
- name: Install Playwright dependencies
run: |
echo "Waiting for Nextcloud at http://localhost:8080..."
uv run playwright install chromium --with-deps
- name: Wait for service to be ready
run: |
echo "Waiting for service at http://localhost:8080/ocs/v2.php/apps/serverinfo/api/v1/info to return 401..."
max_attempts=60
attempt=0
until curl -sSf http://localhost:8080/status.php 2>/dev/null | grep -q '"installed":true'; do
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 "Nextcloud did not become ready in time."
docker compose logs app
echo "Service did not become ready in time."
exit 1
fi
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
echo "Attempt $attempt/$max_attempts: Service not ready, sleeping for 5 seconds..."
sleep 5
done
echo "Nextcloud is ready."
echo "Service is ready (returned 401)."
# Wait for the MCP service to be healthy
- name: Wait for MCP service (${{ matrix.mode }})
run: |
echo "Waiting for MCP service on port ${{ matrix.wait-port }}..."
max_attempts=30
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:${{ matrix.wait-port }}/health 2>/dev/null | grep -qE "200|404|405"; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "MCP service did not become ready in time."
docker compose --profile ${{ matrix.profile }} logs
exit 1
fi
echo "Attempt $attempt/$max_attempts: Not ready, sleeping 5s..."
sleep 5
done
echo "MCP service is ready on port ${{ matrix.wait-port }}."
- name: Verify OIDC configuration
if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
run: |
echo "=== OIDC Discovery ==="
curl -s http://localhost:8080/.well-known/openid-configuration | jq .
echo "=== OIDC App Status ==="
docker compose exec -T app php occ app:list --output=json 2>/dev/null | jq '.enabled.oidc // "NOT INSTALLED"'
- name: Run tests (${{ matrix.mode }})
# Add subsequent steps here, e.g., running tests
- name: Run tests
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
uv run pytest -v \
--log-cli-level=WARN \
-m '${{ matrix.markers }}' \
-o "addopts=-p no:asyncio" \
--timeout=300 \
${{ matrix.extra-args }}
- name: Collect service logs on failure
if: failure()
run: docker compose --profile ${{ matrix.profile }} logs --tail=500 > /tmp/docker-compose-logs.txt 2>&1
- name: Upload debug artifacts
if: failure()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: debug-${{ matrix.mode }}-nc${{ matrix.nextcloud_version }}
path: |
/tmp/*.png
/tmp/docker-compose-logs.txt
retention-days: 7
if-no-files-found: ignore
uv run pytest -v --log-cli-level=WARN -m unit -m smoke
-3
View File
@@ -4,6 +4,3 @@
[submodule "third_party/notes"]
path = third_party/notes
url = https://github.com/cbcoutinho/notes
[submodule "third_party/astrolabe"]
path = third_party/astrolabe
url = https://github.com/cbcoutinho/astrolabe
-294
View File
@@ -5,300 +5,6 @@ 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.65.0 (2026-03-03)
### Feat
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
- add Docker Compose profiles and Login Flow v2 service
### Fix
- replace assert with proper guard and invalidate scope cache after provisioning
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
- address review feedback — security, caching, CI 429 retry
- skip keycloak hook when profile inactive and update stale PRM test
- address remaining PR #589 review findings
- address PR #589 review findings
- address PR review issues for Login Flow v2
- address PR #589 review feedback (round 2)
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
- **ci**: fix health check timeout and per-profile MCP server URL routing
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
- address PR #589 review feedback for Login Flow v2
- **ci**: fix integration test collection and skip Playwright in CI
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
- **ci**: keep third_party mount, always build submodules in CI
- **ci**: revert accidental third_party mount, use compose override for OIDC
- **ci**: don't block integration matrix on unit-test failures
## v0.64.5 (2026-03-03)
### Fix
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
## v0.64.4 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## v0.64.3 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## v0.64.2 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## v0.64.1 (2026-02-18)
### Fix
- **deps**: update dependency mcp to >=1.26,<1.27
## v0.64.0 (2026-02-16)
### Feat
- add self-signed SSL certificate support for Nextcloud connections
### Fix
- add type: ignore for caldav ssl_verify_cert parameter
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
## v0.63.5 (2026-02-16)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## v0.63.4 (2026-02-08)
### Fix
- strip whitespace from category names when splitting
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
## v0.63.3 (2026-02-08)
### Fix
- expand recurring events in date-range queries
## v0.63.2 (2026-02-07)
### Fix
- use CalDAV time-range filter for calendar date range queries
## v0.63.1 (2026-02-03)
### Fix
- **helm**: add backward compatibility for legacy persistence configs
## v0.63.0 (2026-01-28)
### Feat
- **astrolabe**: add background token refresh job
### Fix
- **astrolabe**: add pagination and psalm fixes for token refresh
- **astrolabe**: add locking to prevent token refresh race condition
- **astrolabe**: add issued_at to on-demand token refresh
## v0.62.0 (2026-01-26)
### Feat
- **scripts**: add database query helpers for development
### Fix
- **astrolabe**: resolve Psalm type errors in PDF preview code
- **astrolabe**: fix Psalm baseline and ESLint import order
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
- **astrolabe**: improve error messages for authorization issues
- **astrolabe**: rename OAuthController and fix app password check
- **tests**: improve Astrolabe integration test reliability
- **astrolabe**: update Plotly title attributes for v3 compatibility
- **deps**: update dependency plotly.js-dist-min to v3
### Refactor
- **api**: split management.py into domain-focused modules
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
## v0.61.5 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## v0.61.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## v0.61.3 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## v0.61.2 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
## v0.61.1 (2026-01-15)
### Fix
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## v0.61.0 (2026-01-14)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## v0.60.4 (2026-01-12)
### Fix
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
## v0.60.3 (2025-12-31)
### Fix
- **deck**: Always preserve fields in update_card for partial updates
- **astrolabe**: Fix CSS loading for Nextcloud apps
- **astrolabe**: Fix revoke access button HTTP method mismatch
## v0.60.2 (2025-12-29)
### Fix
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
## v0.60.1 (2025-12-26)
### Fix
- **mcp**: Move all imports to the top of modules
## v0.60.0 (2025-12-26)
### Feat
- Remove URL rewriting in favor of proper nextcloud config
- **helm**: migrate to new environment variable naming convention
- Migrate to vue 3
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
### Fix
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
- **auth**: Skip issuer validation for management API tokens
- Use settings.enable_offline_access for env var consolidation
- Add required config.py attributes
- **docker**: remove overwritehost to fix container-to-container DCR
- **deps**: update dependency @nextcloud/vue to v9
- **deps**: update dependency vue to v3
### Refactor
- **auth**: Decouple BasicAuth and OAuth authentication strategies
## v0.59.1 (2025-12-22)
### Fix
- **helm**: set OIDC client env vars when using existingSecret
- **helm**: trigger chart release workflow on helm chart tags
## v0.59.0 (2025-12-22)
### Feat
- **helm**: add support for multi-user BasicAuth mode
### Fix
- **helm**: address PR #447 reviewer feedback
- **helm**: include MCP server version bumps in changelog pattern
## v0.58.0 (2025-12-22)
### Feat
- **config**: enable DCR for multi-user BasicAuth with offline access
- **astrolabe**: implement app password provisioning for multi-user background sync
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
## v0.57.0 (2025-12-20)
### Feat
- **auth**: add multi-user BasicAuth pass-through mode
- **astrolabe**: add dynamic MCP server configuration for testing
### Fix
- **config**: address reviewer feedback
### Refactor
- **config**: centralize configuration validation and simplify startup
## v0.56.2 (2025-12-20)
### Fix
-53
View File
@@ -239,25 +239,6 @@ uv run python -m tests.load.benchmark --output results.json --verbose
**Credentials**: root/password, nextcloud/password, database: `nextcloud`
### Quick Query Script (Recommended for Agents)
Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`:
```bash
# Basic query
./scripts/dbquery.py "SELECT COUNT(*) FROM oc_users"
# Vertical output (one column per line) - useful for wide tables
./scripts/dbquery.py -E "SELECT * FROM oc_oidc_clients LIMIT 1"
# With different credentials
./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES"
```
### Direct Docker Access
For interactive sessions or complex operations:
```bash
# Connect to database
docker compose exec db mariadb -u root -ppassword nextcloud
@@ -283,40 +264,6 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \
- `oc_oidc_registration_tokens` - RFC 7592 registration tokens
- `oc_oidc_redirect_uris` - Redirect URIs
### SQLite Databases (MCP Services)
Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers:
```bash
# List tables
./scripts/sqlitequery.py ".tables"
# Query specific service
./scripts/sqlitequery.py -s oauth "SELECT * FROM refresh_tokens"
./scripts/sqlitequery.py -s keycloak "SELECT * FROM oauth_clients"
./scripts/sqlitequery.py -s basic "SELECT * FROM app_passwords"
# With column headers
./scripts/sqlitequery.py --column "SELECT * FROM audit_logs LIMIT 5"
# JSON output
./scripts/sqlitequery.py --json "SELECT * FROM oauth_sessions"
# View schema
./scripts/sqlitequery.py -s oauth ".schema refresh_tokens"
```
**Services**: `mcp` (default), `oauth`, `keycloak`, `basic`
**SQLite Tables**:
- `refresh_tokens` - OAuth refresh tokens with user profiles
- `audit_logs` - Security audit trail
- `oauth_clients` - DCR OAuth client credentials
- `oauth_sessions` - OAuth flow session state
- `registered_webhooks` - Webhook registrations
- `app_passwords` - Multi-user BasicAuth passwords
- `alembic_version` - Migration tracking
## Architecture Quick Reference
**For detailed architecture, see:**
+13 -3
View File
@@ -2,7 +2,7 @@
## Version Management
This monorepo uses commitizen for version management with **independent versioning** for two components:
This monorepo uses commitizen for version management with **independent versioning** for three components:
### Components
@@ -10,8 +10,7 @@ This monorepo uses commitizen for version management with **independent versioni
|-----------|-------|--------------|-------------|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
> **Note:** The Astrolabe Nextcloud app has been moved to its own repository at [cbcoutinho/astrolabe](https://github.com/cbcoutinho/astrolabe).
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
### Commit Message Format
@@ -25,6 +24,10 @@ 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:
@@ -37,6 +40,7 @@ feat: add new feature # → MCP server (v0.54.0)
#### 1. Make Changes with Scoped Commits
```bash
git commit -m "feat(astrolabe): add dark mode toggle"
git commit -m "feat(helm): add ingress annotations"
git commit -m "feat(mcp): add calendar sync"
```
@@ -54,6 +58,10 @@ git commit -m "feat(mcp): add calendar sync"
# → Creates tag: nextcloud-mcp-server-0.54.0
# → Updates: Chart.yaml:version
# Bump Astrolabe (reads commits with scope=astrolabe)
./scripts/bump-astrolabe.sh
# → Creates tag: astrolabe-v0.2.0
# → Updates: info.xml, package.json
```
#### 3. Push Tags
@@ -68,6 +76,7 @@ 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
@@ -92,6 +101,7 @@ uv run cz --config .cz.toml bump --increment MINOR
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
### Chart.yaml Version vs appVersion
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
COPY --from=ghcr.io/astral-sh/uv:0.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /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 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:f3fa41d74a768c2fce8016b98c191ae8c1bacd8f1152870a3f9f87d350920b7c
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.10.7@sha256:edd1fd89f3e5b005814cc8f777610445d7b7e3ed05361f9ddfae67bebfe8456a /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)
+4 -27
View File
@@ -55,15 +55,6 @@ http://127.0.0.1:8000/sse
http://127.0.0.1:8000/mcp
```
**Docker Compose Profiles** (for development/testing):
```bash
docker compose --profile single-user up -d # Port 8000
docker compose --profile multi-user-basic up -d # Port 8003
docker compose --profile oauth up -d # Port 8001
docker compose --profile login-flow up -d # Port 8004
```
**Next Steps:**
- Connect your MCP client (Claude Desktop, IDEs, `mcp dev`, etc.)
- See [docs/installation.md](docs/installation.md) for other deployment options (local, Kubernetes)
@@ -108,33 +99,19 @@ Want to see another Nextcloud app supported? [Open an issue](https://github.com/
### Authentication Modes
The server supports four authentication modes:
The server supports two authentication modes:
**Single-User (BasicAuth):**
**Single-User Mode (BasicAuth):**
- One set of credentials shared by all MCP clients
- Simple setup: username + app password in environment variables
- All clients access Nextcloud as the same user
- Best for: Personal use, development, single-user deployments
**Multi-User (BasicAuth Pass-Through):**
- MCP clients send credentials via Authorization header
- Server passes through to Nextcloud (stateless by default)
- Optional offline access for background operations (`ENABLE_MULTI_USER_BASIC_AUTH=true`)
- Best for: Multi-user setups without OAuth infrastructure
**Multi-User (OAuth):**
**Multi-User Mode (OAuth):**
- Each MCP client authenticates separately with their own Nextcloud account
- Per-user scopes and permissions (clients only see tools they're authorized for)
- More secure: tokens expire, credentials never shared with server
- Best for: Teams, multi-user deployments, production environments with multiple users
- Requires: Patches to the `user_oidc` app (experimental)
**Multi-User (Login Flow v2):**
- Uses Nextcloud's native Login Flow v2 to obtain per-user app passwords
- No OAuth patches required — works with stock Nextcloud
- Each user authenticates via browser, server manages app passwords
- Best for: Multi-user deployments without OAuth infrastructure (`ENABLE_LOGIN_FLOW=true`)
- Experimental: See [ADR-022](docs/ADR-022-deployment-mode-consolidation.md) for details
See [docs/authentication.md](docs/authentication.md) for detailed setup instructions.
@@ -150,7 +127,7 @@ This enables natural language queries and helps discover related content across
> [!NOTE]
> **Semantic Search is experimental and opt-in:**
> - Disabled by default (`ENABLE_SEMANTIC_SEARCH=false`)
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports Notes app only (multi-app support planned)
> - Requires additional infrastructure: vector database + embedding service
> - Answer generation (`nc_semantic_search_answer`) requires MCP client sampling support
@@ -4,8 +4,8 @@ set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
# Set overwrite.cli.url to the external URL for OIDC discovery
# This ensures OAuth flows redirect to the correct external URL
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
# which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80)
# 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"
@@ -1,6 +0,0 @@
#!/bin/bash
set -euox pipefail
echo "Disabling bruteforce protection and rate limiting for dev/CI..."
php /var/www/html/occ config:system:set auth.bruteforce.protection.enabled --value=false --type=boolean
php /var/www/html/occ config:system:set ratelimit.protection.enabled --value=false --type=boolean
echo "Bruteforce protection and rate limiting disabled."
@@ -13,14 +13,6 @@ echo "===================================================================="
echo "Configuring user_oidc provider for Keycloak..."
echo "===================================================================="
# Quick check: Is keycloak service in the Docker network?
# When the keycloak profile is not active, this hostname won't resolve.
if ! getent hosts keycloak >/dev/null 2>&1; then
echo " Keycloak service not detected in Docker network (profile not active)"
echo " Skipping keycloak provider configuration"
exit 0
fi
# Wait for Keycloak to be ready and realm to be available
echo "Waiting for Keycloak realm to be available..."
MAX_RETRIES=30
@@ -2,7 +2,7 @@
set -euox pipefail
echo "Installing Astrolabe app..."
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
@@ -30,7 +30,55 @@ else
php /var/www/html/occ app:enable astrolabe
fi
echo "Astrolabe app installed successfully"
echo ""
echo "Note: MCP server configuration is managed dynamically during tests"
echo " to support testing multiple MCP server deployments."
# 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"
@@ -1,17 +0,0 @@
#!/bin/bash
# Configure MCP server URL for Astrolabe background sync
# This URL is used by Astrolabe to send app passwords to the MCP server
set -e
if [ -z "${MCP_SERVER_URL:-}" ]; then
echo "MCP_SERVER_URL not set, skipping Astrolabe MCP server URL configuration"
exit 0
fi
echo "Configuring MCP server URL: $MCP_SERVER_URL"
# Set the mcp_server_url in config.php via occ
php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL"
echo "MCP server URL configured successfully"
+2 -3
View File
@@ -1,6 +1,6 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.58.3"
version = "0.54.0"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
@@ -18,8 +18,7 @@ ignored_tag_formats = [
]
# Filter commits by scope
# Includes helm-scoped commits AND MCP server version bumps (which update appVersion)
[tool.commitizen.customize]
changelog_pattern = "^((feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:|bump: version.*→.*)"
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}}"
-449
View File
@@ -14,455 +14,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.58.3 (2026-03-16)
## nextcloud-mcp-server-0.58.2 (2026-03-14)
## nextcloud-mcp-server-0.58.1 (2026-03-03)
## nextcloud-mcp-server-0.58.0 (2026-03-03)
### Feat
- **auth**: implement OAuth AS proxy to fix audience mismatch (ADR-023)
- **ci**: add Nextcloud version matrix (NC 31, 32, 33)
- **helm**: add login-flow auth mode to Helm chart (ADR-022)
- add Docker Compose profiles and Login Flow v2 service
### Fix
- replace assert with proper guard and invalidate scope cache after provisioning
- disable NC rate limiting in dev/CI and add token endpoint diagnostics
- address review feedback — security, caching, CI 429 retry
- skip keycloak hook when profile inactive and update stale PRM test
- address remaining PR #589 review findings
- address PR #589 review findings
- address PR review issues for Login Flow v2
- address PR #589 review feedback (round 2)
- **ci**: remove dev OIDC mount to fix HTTP 500 in single-user/multi-user-basic
- **ci**: fix health check timeout and per-profile MCP server URL routing
- **ci**: fix PHP gating, add multi-user-basic matrix entry, upload debug artifacts
- address PR #589 review feedback for Login Flow v2
- **ci**: fix integration test collection and skip Playwright in CI
- **test**: fix 17 pre-existing unit test failures and add astrolabe CI build
- **ci**: keep third_party mount, always build submodules in CI
- **ci**: revert accidental third_party mount, use compose override for OIDC
- **ci**: don't block integration matrix on unit-test failures
## nextcloud-mcp-server-0.57.94 (2026-03-03)
### Fix
- handle pythonvCard4 dict-format fields and missing phone numbers (#601)
## nextcloud-mcp-server-0.57.93 (2026-03-03)
## nextcloud-mcp-server-0.57.92 (2026-03-02)
## nextcloud-mcp-server-0.57.91 (2026-03-02)
## nextcloud-mcp-server-0.57.90 (2026-03-01)
## nextcloud-mcp-server-0.57.89 (2026-03-01)
## nextcloud-mcp-server-0.57.88 (2026-03-01)
## nextcloud-mcp-server-0.57.87 (2026-03-01)
## nextcloud-mcp-server-0.57.86 (2026-02-26)
### Fix
- **deps**: update dependency icalendar to v7
## nextcloud-mcp-server-0.57.85 (2026-02-25)
## nextcloud-mcp-server-0.57.84 (2026-02-25)
## nextcloud-mcp-server-0.57.83 (2026-02-25)
## nextcloud-mcp-server-0.57.82 (2026-02-25)
## nextcloud-mcp-server-0.57.81 (2026-02-25)
## nextcloud-mcp-server-0.57.80 (2026-02-24)
## nextcloud-mcp-server-0.57.79 (2026-02-24)
## nextcloud-mcp-server-0.57.78 (2026-02-24)
## nextcloud-mcp-server-0.57.77 (2026-02-24)
## nextcloud-mcp-server-0.57.76 (2026-02-24)
## nextcloud-mcp-server-0.57.75 (2026-02-23)
## nextcloud-mcp-server-0.57.74 (2026-02-21)
## nextcloud-mcp-server-0.57.73 (2026-02-21)
### Fix
- address PR #574 fourth review round
- address PR #574 third review round
- address PR #574 second review round
- address PR #574 review comments
- wrap raw list returns in response models to produce single TextContent block
## nextcloud-mcp-server-0.57.72 (2026-02-20)
## nextcloud-mcp-server-0.57.71 (2026-02-20)
## nextcloud-mcp-server-0.57.70 (2026-02-20)
### Fix
- address PR #571 review comments
- resolve stale credentials causing astrolabe background sync test failures
### Refactor
- enforce PLC0415 (import-outside-top-level) for source code
## nextcloud-mcp-server-0.57.69 (2026-02-20)
## nextcloud-mcp-server-0.57.68 (2026-02-19)
## nextcloud-mcp-server-0.57.67 (2026-02-19)
## nextcloud-mcp-server-0.57.66 (2026-02-18)
## nextcloud-mcp-server-0.57.65 (2026-02-18)
## nextcloud-mcp-server-0.57.64 (2026-02-18)
## nextcloud-mcp-server-0.57.63 (2026-02-18)
## nextcloud-mcp-server-0.57.62 (2026-02-18)
### Fix
- **deps**: update dependency mcp to >=1.26,<1.27
## nextcloud-mcp-server-0.57.61 (2026-02-18)
## nextcloud-mcp-server-0.57.60 (2026-02-18)
## nextcloud-mcp-server-0.57.59 (2026-02-18)
## nextcloud-mcp-server-0.57.58 (2026-02-18)
## nextcloud-mcp-server-0.57.57 (2026-02-18)
## nextcloud-mcp-server-0.57.56 (2026-02-18)
## nextcloud-mcp-server-0.57.55 (2026-02-17)
## nextcloud-mcp-server-0.57.54 (2026-02-17)
## nextcloud-mcp-server-0.57.53 (2026-02-17)
## nextcloud-mcp-server-0.57.52 (2026-02-17)
## nextcloud-mcp-server-0.57.51 (2026-02-16)
### Feat
- add self-signed SSL certificate support for Nextcloud connections
### Fix
- add type: ignore for caldav ssl_verify_cert parameter
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
## nextcloud-mcp-server-0.57.50 (2026-02-16)
## nextcloud-mcp-server-0.57.49 (2026-02-16)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## nextcloud-mcp-server-0.57.48 (2026-02-15)
## nextcloud-mcp-server-0.57.47 (2026-02-15)
## nextcloud-mcp-server-0.57.46 (2026-02-12)
## nextcloud-mcp-server-0.57.45 (2026-02-12)
## nextcloud-mcp-server-0.57.44 (2026-02-11)
## nextcloud-mcp-server-0.57.43 (2026-02-11)
## nextcloud-mcp-server-0.57.42 (2026-02-08)
### Fix
- strip whitespace from category names when splitting
- handle categories, recurrence_rule, attendees, and reminder_minutes in update_event
## nextcloud-mcp-server-0.57.41 (2026-02-08)
### Fix
- expand recurring events in date-range queries
## nextcloud-mcp-server-0.57.40 (2026-02-07)
### Fix
- use CalDAV time-range filter for calendar date range queries
## nextcloud-mcp-server-0.57.39 (2026-02-07)
## nextcloud-mcp-server-0.57.38 (2026-02-07)
## nextcloud-mcp-server-0.57.37 (2026-02-06)
## nextcloud-mcp-server-0.57.36 (2026-02-06)
## nextcloud-mcp-server-0.57.35 (2026-02-06)
## nextcloud-mcp-server-0.57.34 (2026-02-06)
## nextcloud-mcp-server-0.57.33 (2026-02-06)
## nextcloud-mcp-server-0.57.32 (2026-02-06)
## nextcloud-mcp-server-0.57.31 (2026-02-06)
## nextcloud-mcp-server-0.57.30 (2026-02-06)
## nextcloud-mcp-server-0.57.29 (2026-02-04)
## nextcloud-mcp-server-0.57.28 (2026-02-03)
## nextcloud-mcp-server-0.57.27 (2026-02-03)
### Fix
- **helm**: add backward compatibility for legacy persistence configs
## nextcloud-mcp-server-0.57.26 (2026-01-31)
## nextcloud-mcp-server-0.57.25 (2026-01-31)
## nextcloud-mcp-server-0.57.24 (2026-01-31)
## nextcloud-mcp-server-0.57.23 (2026-01-30)
## nextcloud-mcp-server-0.57.22 (2026-01-30)
## nextcloud-mcp-server-0.57.21 (2026-01-30)
## nextcloud-mcp-server-0.57.20 (2026-01-29)
## nextcloud-mcp-server-0.57.19 (2026-01-28)
## nextcloud-mcp-server-0.57.18 (2026-01-28)
## nextcloud-mcp-server-0.57.17 (2026-01-28)
## nextcloud-mcp-server-0.57.16 (2026-01-28)
### Feat
- **astrolabe**: add background token refresh job
### Fix
- **astrolabe**: add pagination and psalm fixes for token refresh
- **astrolabe**: add locking to prevent token refresh race condition
- **astrolabe**: add issued_at to on-demand token refresh
## nextcloud-mcp-server-0.57.15 (2026-01-26)
### Feat
- **scripts**: add database query helpers for development
### Fix
- **astrolabe**: resolve Psalm type errors in PDF preview code
- **astrolabe**: fix Psalm baseline and ESLint import order
- **astrolabe**: load pdfjs-dist externally to fix PDF viewer
- **astrolabe**: improve error messages for authorization issues
- **astrolabe**: rename OAuthController and fix app password check
- **tests**: improve Astrolabe integration test reliability
- **astrolabe**: update Plotly title attributes for v3 compatibility
- **deps**: update dependency plotly.js-dist-min to v3
### Refactor
- **api**: split management.py into domain-focused modules
- **astrolabe**: replace client-side PDF.js with server-side PyMuPDF rendering
## nextcloud-mcp-server-0.57.14 (2026-01-26)
## nextcloud-mcp-server-0.57.13 (2026-01-24)
## nextcloud-mcp-server-0.57.12 (2026-01-20)
## nextcloud-mcp-server-0.57.11 (2026-01-20)
## nextcloud-mcp-server-0.57.10 (2026-01-19)
## nextcloud-mcp-server-0.57.9 (2026-01-19)
## nextcloud-mcp-server-0.57.8 (2026-01-18)
## nextcloud-mcp-server-0.57.7 (2026-01-17)
### Fix
- **astrolabe**: improve token refresh error handling and validation
- **astrolabe**: delete stale tokens when refresh fails
- **astrolabe**: resolve CI failures for code quality checks
- **astrolabe**: use internal URL for OAuth token refresh
### Refactor
- **astrolabe**: add PHP property types to fix Psalm errors
- **astrolabe**: upgrade to @nextcloud/vue 9.3.3 API
## nextcloud-mcp-server-0.57.6 (2026-01-16)
## nextcloud-mcp-server-0.57.5 (2026-01-16)
## nextcloud-mcp-server-0.57.4 (2026-01-16)
### Fix
- **astrolabe**: Address reviewer feedback for hybrid mode
- **astrolabe**: Fix NcSelect options and CSS loading
- **astrolabe**: fix OAuth flow and settings UI for hybrid mode
- **api**: return OIDC config in hybrid mode for Astrolabe OAuth flow
## nextcloud-mcp-server-0.57.3 (2026-01-15)
## nextcloud-mcp-server-0.57.2 (2026-01-15)
### Fix
- **astrolabe**: address review feedback for Vue 3 bindings
- **astrolabe**: update Vue component bindings for Vue 3 compatibility
## nextcloud-mcp-server-0.57.1 (2026-01-15)
### Fix
- **ci**: bump helm chart version when MCP appVersion changes
- **astrolabe**: define appName and appVersion for @nextcloud/vue
## nextcloud-mcp-server-0.57.0 (2026-01-15)
### Feat
- Add rate limiting and extract helpers for app password endpoints
### Fix
- Add missing annotations for deck remove/unassign operations
- **auth**: Store app passwords locally for multi-user BasicAuth background sync
- **deck**: use correct endpoint for reorder_card to fix cross-stack moves
- **deck**: Always preserve fields in update_card for partial updates
- **astrolabe**: Fix CSS loading for Nextcloud apps
- **astrolabe**: Fix revoke access button HTTP method mismatch
### Refactor
- Use get_settings() for vector sync enabled check
- Extract storage helper and improve PHP error handling
## nextcloud-mcp-server-0.56.2 (2025-12-29)
### Fix
- **oauth**: Enable browser OAuth routes for Management API in hybrid mode
## nextcloud-mcp-server-0.56.1 (2025-12-26)
### Fix
- **mcp**: Move all imports to the top of modules
## nextcloud-mcp-server-0.56.0 (2025-12-26)
### Feat
- Remove URL rewriting in favor of proper nextcloud config
- **helm**: migrate to new environment variable naming convention
- Migrate to vue 3
- **astrolabe**: upgrade to Vue 3 and @nextcloud/vue 9
### Fix
- **tests**: Add singleton reset fixture to prevent anyio.WouldBlock errors
- **tests**: Fix integration test failures in qdrant, sampling, and rag tests
- **auth**: Skip issuer validation for management API tokens
- Use settings.enable_offline_access for env var consolidation
- Add required config.py attributes
- **docker**: remove overwritehost to fix container-to-container DCR
- **deps**: update dependency @nextcloud/vue to v9
- **deps**: update dependency vue to v3
### Refactor
- **auth**: Decouple BasicAuth and OAuth authentication strategies
## nextcloud-mcp-server-0.55.2 (2025-12-22)
### Fix
- **helm**: set OIDC client env vars when using existingSecret
## nextcloud-mcp-server-0.55.1 (2025-12-22)
### Fix
- **helm**: trigger chart release workflow on helm chart tags
## nextcloud-mcp-server-0.55.0 (2025-12-22)
### BREAKING CHANGE
- MCP server now bumps for ANY conventional commit except
those explicitly scoped to helm or astrolabe.
### Feat
- **helm**: add support for multi-user BasicAuth mode
- **config**: enable DCR for multi-user BasicAuth with offline access
- **astrolabe**: implement app password provisioning for multi-user background sync
- **config**: consolidate configuration with smart dependency resolution (ADR-021)
- **auth**: add multi-user BasicAuth pass-through mode
- **astrolabe**: add dynamic MCP server configuration for testing
- **ci**: add --increment flag to bump scripts for manual version control
### Fix
- **helm**: address PR #447 reviewer feedback
- **helm**: include MCP server version bumps in changelog pattern
- **config**: address reviewer feedback
- **astrolabe**: screenshots in info.xml
- **astrolabe**: screenshots in info.xml
- **astrolabe**: Update screenshots
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
- **astrolabe**: add contents:write permission to appstore workflow
- **astrolabe**: update commitizen pattern to properly update info.xml version
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
- **astrolabe**: info.xml
- **ci**: push all tags explicitly in bump workflow
- **ci**: make MCP server default bump target for all non-scoped commits
- **ci**: restrict docker build to MCP server tags only
- **ci**: correct appstore-push-action version to v1.0.4
### Refactor
- **config**: centralize configuration validation and simplify startup
## nextcloud-mcp-server-0.54.0 (2025-12-19)
### Feat
+4 -4
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.17.0
version: 1.16.2
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.47.0
digest: sha256:08d589dd1b3386e8e8a2ac2c03a2194218ab12ed9e02016e7b981e554385dd11
generated: "2026-03-02T11:15:27.688786078Z"
version: 1.36.0
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
generated: "2025-12-14T11:07:07.024787592Z"
+4 -13
View File
@@ -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.58.3
appVersion: "0.65.0"
version: 0.54.0
appVersion: "0.56.2"
keywords:
- nextcloud
- mcp
@@ -25,21 +25,12 @@ annotations:
# Grafana dashboard support
grafana_dashboard: "true"
grafana_dashboard_folder: "Nextcloud MCP"
artifacthub.io/changes: |
- kind: added
description: Login Flow v2 auth mode for Helm chart (ADR-022)
- kind: added
description: Multi-user BasicAuth guidance in post-install NOTES
- kind: added
description: Version and changelog info in post-install NOTES
- kind: changed
description: Updated appVersion to 0.64.4
dependencies:
- name: qdrant
version: "1.17.0"
version: "1.16.2"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.47.0"
version: "1.36.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+14 -35
View File
@@ -99,11 +99,11 @@ ingress:
|-----------|-------------|---------|
| `nextcloud.host` | URL of your Nextcloud instance (required) | `""` |
| `nextcloud.mcpServerUrl` | MCP server URL for OAuth callbacks (OAuth only, optional) | Smart default* |
| `nextcloud.publicIssuerUrl` | Public URL for browser-accessible OAuth authorization endpoint (OAuth only, optional) | Smart default** |
| `nextcloud.publicIssuerUrl` | Public issuer URL for OAuth (OAuth only, optional) | Smart default** |
**Smart Defaults:**
- `*mcpServerUrl`: If not set, automatically uses ingress host (if enabled) or `http://localhost:8000` (for port-forward setups)
- `**publicIssuerUrl`: If not set, defaults to `nextcloud.host`. **Only used for authorization endpoints** that browsers must access. All server-to-server endpoints (token, JWKS, introspection, userinfo) use URLs from OIDC discovery without rewriting
- `**publicIssuerUrl`: If not set, automatically defaults to `nextcloud.host` (which works when both clients and MCP server access Nextcloud at the same URL)
#### Authentication
@@ -118,25 +118,6 @@ ingress:
| `auth.oauth.persistence.enabled` | Enable persistent storage for OAuth | `true` |
| `auth.oauth.persistence.size` | Size of OAuth storage PVC | `100Mi` |
#### Data Storage
The `/app/data` directory is used for application data (token databases, Qdrant persistent storage, etc.). It is always mounted as writable to support the read-only root filesystem security context.
| Parameter | Description | Default |
|-----------|-------------|---------|
| `dataStorage.enabled` | Enable persistent storage for `/app/data` | `false` |
| `dataStorage.size` | Size of data storage PVC | `1Gi` |
| `dataStorage.storageClass` | Storage class (leave empty for default) | `""` |
| `dataStorage.accessMode` | Access mode | `ReadWriteOnce` |
| `dataStorage.existingClaim` | Use existing PVC | `""` |
**When to enable persistence:**
- Multi-user basic auth with offline access (stores `tokens.db`)
- Qdrant persistent mode (stores vector database)
- Any feature requiring persistent app data
**When persistence is disabled:** Uses `emptyDir` (non-persistent, data lost on pod restart, but directory remains writable).
#### MCP Server Configuration
| Parameter | Description | Default |
@@ -227,16 +208,16 @@ The application exposes HTTP health check endpoints:
#### Vector Search & Semantic Capabilities (Optional)
Enable semantic search capabilities with BM25 hybrid search by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
Enable semantic search capabilities by deploying a vector database (Qdrant) and embedding service (Ollama or OpenAI).
**Semantic Search Configuration:**
**Vector Sync Configuration:**
| Parameter | Description | Default |
|-----------|-------------|---------|
| `semanticSearch.enabled` | Enable semantic search and background vector synchronization | `false` |
| `semanticSearch.scanInterval` | Scan interval in seconds | `3600` |
| `semanticSearch.processorWorkers` | Number of concurrent processor workers | `3` |
| `semanticSearch.queueMaxSize` | Maximum queue size for pending documents | `10000` |
| `vectorSync.enabled` | Enable background vector synchronization | `false` |
| `vectorSync.scanInterval` | Scan interval in seconds | `3600` |
| `vectorSync.processorWorkers` | Number of concurrent processor workers | `3` |
| `vectorSync.queueMaxSize` | Maximum queue size for pending documents | `10000` |
**Document Chunking Configuration:**
@@ -446,7 +427,7 @@ nextcloud:
host: https://cloud.example.com
# mcpServerUrl and publicIssuerUrl are optional!
# If not set, mcpServerUrl defaults to ingress host or localhost
# publicIssuerUrl defaults to nextcloud.host (only used for browser-accessible auth endpoint)
# publicIssuerUrl defaults to nextcloud.host
auth:
mode: oauth
@@ -478,7 +459,7 @@ This example shows OAuth without pre-registered credentials (using DCR) and opti
nextcloud:
host: https://cloud.example.com
# mcpServerUrl will automatically use ingress host (https://mcp.example.com)
# publicIssuerUrl will automatically default to nextcloud.host (only used for browser-accessible auth endpoint)
# publicIssuerUrl will automatically default to nextcloud.host
auth:
mode: oauth
@@ -556,8 +537,8 @@ auth:
username: admin
password: secure-password
# Enable semantic search
semanticSearch:
# Enable vector sync
vectorSync:
enabled: true
scanInterval: 1800 # Scan every 30 minutes
processorWorkers: 5
@@ -595,7 +576,7 @@ ollama:
Or use an external Ollama instance:
```yaml
semanticSearch:
vectorSync:
enabled: true
qdrant:
@@ -611,7 +592,7 @@ ollama:
Or use OpenAI for embeddings:
```yaml
semanticSearch:
vectorSync:
enabled: true
qdrant:
@@ -708,9 +689,7 @@ Readiness (returns 200 if ready, 503 if not ready):
1. **Connection refused to Nextcloud**
- Verify `nextcloud.host` is accessible from the Kubernetes cluster
- For OAuth mode: Ensure MCP server can reach OIDC discovery endpoints (token, JWKS, introspection, userinfo URLs)
- Check network policies and firewall rules
- Note: Do not use internal Docker hostnames (like `http://app:80`) for `nextcloud.host` - use externally resolvable URLs
2. **Authentication failures**
- For basic auth: verify username/password are correct
@@ -57,28 +57,6 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
IMPORTANT: OAuth mode is experimental and requires patches to the user_oidc app.
See: https://github.com/cbcoutinho/nextcloud-mcp-server#authentication
{{- else if eq .Values.auth.mode "multi-user-basic" }}
3. Multi-User BasicAuth Mode (Pass-Through):
- Users provide credentials via Authorization header
- Connected to: {{ .Values.nextcloud.host }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
- Offline access: Enabled (background operations with app passwords)
- Token storage: {{ .Values.auth.multiUserBasic.tokenStorageDb }}
{{- else }}
- Offline access: Disabled (stateless pass-through)
{{- end }}
{{- else if eq .Values.auth.mode "login-flow" }}
3. Login Flow v2 Mode (Experimental, ADR-022):
- Server URL: {{ include "nextcloud-mcp-server.mcpServerUrl" . }}
- Connected to: {{ .Values.nextcloud.host }}
- Token storage: {{ .Values.auth.loginFlow.tokenStorageDb }}
Users authenticate via Nextcloud's native Login Flow v2 — no OAuth patches required.
Each user gets a per-device app password managed by the MCP server.
IMPORTANT: Login Flow v2 is experimental. See ADR-022 for details.
{{- end }}
{{- if .Values.documentProcessing.enabled }}
@@ -91,12 +69,12 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
{{- end }}
{{- end }}
{{- if .Values.semanticSearch.enabled }}
{{- if .Values.vectorSync.enabled }}
5. Semantic Search & Vector Capabilities:
- Semantic Search: Enabled
- Scan Interval: {{ .Values.semanticSearch.scanInterval }}s
- Processor Workers: {{ .Values.semanticSearch.processorWorkers }}
5. Vector Search & Semantic Capabilities:
- Vector Sync: Enabled
- Scan Interval: {{ .Values.vectorSync.scanInterval }}s
- Processor Workers: {{ .Values.vectorSync.processorWorkers }}
{{- if .Values.qdrant.enabled }}
- Qdrant: Deployed as subchart ({{ .Release.Name }}-qdrant:6333)
{{- else }}
@@ -142,61 +120,6 @@ Your Nextcloud MCP Server has been deployed in {{ .Values.auth.mode }} authentic
The dashboard JSON is available in the chart at charts/nextcloud-mcp-server/dashboards/nextcloud-mcp-server.json
{{- end }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- if or $legacyMultiUserBasic $legacyQdrant }}
================================================================================
DEPRECATION WARNING
================================================================================
You are using deprecated persistence configuration that will be removed in a
future release. Your deployment will continue to work, but please migrate to
the new unified dataStorage configuration.
Deprecated settings detected:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.* (currently enabled)
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.* (currently enabled)
{{- end }}
To migrate, update your values.yaml:
dataStorage:
enabled: true
{{- if $legacyMultiUserBasic }}
size: {{ .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
size: {{ .Values.qdrant.localPersistence.size }}
{{- end }}
# storageClass: "" # Optional: specify storage class
# existingClaim: "" # Optional: use existing PVC to preserve data
After migrating, remove the deprecated settings:
{{- if $legacyMultiUserBasic }}
- auth.multiUserBasic.persistence.enabled
- auth.multiUserBasic.persistence.size
- auth.multiUserBasic.persistence.storageClass
- auth.multiUserBasic.persistence.accessMode
{{- end }}
{{- if $legacyQdrant }}
- qdrant.localPersistence.enabled
- qdrant.localPersistence.size
- qdrant.localPersistence.storageClass
- qdrant.localPersistence.accessMode
{{- end }}
================================================================================
{{- end }}
Deployed version:
- Chart: {{ .Chart.Version }}
- App: {{ .Chart.AppVersion }}
Full changelog: https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/charts/nextcloud-mcp-server/CHANGELOG.md
For more information and documentation:
- GitHub: https://github.com/cbcoutinho/nextcloud-mcp-server
- Documentation: https://github.com/cbcoutinho/nextcloud-mcp-server#readme
@@ -72,28 +72,6 @@ Create the name of the secret to use for basic auth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for multi-user basic auth
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicSecretName" -}}
{{- if .Values.auth.multiUserBasic.existingSecret }}
{{- .Values.auth.multiUserBasic.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for multi-user basic token storage
*/}}
{{- define "nextcloud-mcp-server.multiUserBasicPvcName" -}}
{{- if .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- .Values.auth.multiUserBasic.persistence.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-token-storage
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for OAuth
*/}}
@@ -105,17 +83,6 @@ Create the name of the secret to use for OAuth
{{- end }}
{{- end }}
{{/*
Create the name of the secret to use for Login Flow v2
*/}}
{{- define "nextcloud-mcp-server.loginFlowSecretName" -}}
{{- if .Values.auth.loginFlow.existingSecret }}
{{- .Values.auth.loginFlow.existingSecret }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-login-flow
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for OAuth storage
*/}}
@@ -138,57 +105,6 @@ Create the name of the PVC to use for Qdrant local persistent storage
{{- end }}
{{- end }}
{{/*
Create the name of the PVC to use for /app/data storage
*/}}
{{- define "nextcloud-mcp-server.dataStoragePvcName" -}}
{{- if .Values.dataStorage.existingClaim }}
{{- .Values.dataStorage.existingClaim }}
{{- else }}
{{- include "nextcloud-mcp-server.fullname" . }}-data-storage
{{- end }}
{{- end }}
{{/*
Determine if data storage PVC should be enabled (backward compatible)
Checks new dataStorage.enabled OR legacy persistence configs
*/}}
{{- define "nextcloud-mcp-server.dataStorageEnabled" -}}
{{- if .Values.dataStorage.enabled -}}
true
{{- else if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled -}}
true
{{- else if eq .Values.auth.mode "login-flow" -}}
true
{{- else if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy multi-user-basic persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyMultiUserBasicPersistence" -}}
{{- if and (eq .Values.auth.mode "multi-user-basic") .Values.auth.multiUserBasic.enableOfflineAccess .Values.auth.multiUserBasic.persistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Check if legacy qdrant persistence config is being used
*/}}
{{- define "nextcloud-mcp-server.legacyQdrantPersistence" -}}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.dataStorage.enabled) -}}
true
{{- else -}}
false
{{- end -}}
{{- end }}
{{/*
Return the MCP server port
*/}}
@@ -46,10 +46,8 @@ spec:
args:
- "--transport"
- "{{ .Values.mcp.transport }}"
{{- if or (eq .Values.auth.mode "oauth") (eq .Values.auth.mode "login-flow") }}
- "--oauth"
{{- end }}
{{- if eq .Values.auth.mode "oauth" }}
- "--oauth"
- "--oauth-token-type"
- "{{ .Values.auth.oauth.tokenType }}"
{{- end }}
@@ -70,7 +68,7 @@ spec:
- name: NEXTCLOUD_HOST
value: {{ .Values.nextcloud.host | quote }}
{{- if eq .Values.auth.mode "basic" }}
# Basic auth mode (single-user)
# Basic auth mode
- name: NEXTCLOUD_USERNAME
valueFrom:
secretKeyRef:
@@ -81,41 +79,6 @@ spec:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.basicAuthSecretName" . }}
key: {{ .Values.auth.basic.passwordKey }}
{{- else if eq .Values.auth.mode "multi-user-basic" }}
# Multi-user BasicAuth mode (pass-through)
- name: ENABLE_MULTI_USER_BASIC_AUTH
value: "true"
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
{{- if .Values.auth.multiUserBasic.enableOfflineAccess }}
# Background operations with app passwords (replaces deprecated ENABLE_OFFLINE_ACCESS)
- name: ENABLE_BACKGROUND_OPERATIONS
value: "true"
- name: TOKEN_STORAGE_DB
value: {{ .Values.auth.multiUserBasic.tokenStorageDb | quote }}
- name: TOKEN_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.multiUserBasic.scopes | quote }}
{{- if or .Values.auth.multiUserBasic.clientId .Values.auth.multiUserBasic.existingSecret }}
# Static OAuth credentials (optional - uses DCR if not provided)
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientIdKey }}
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.multiUserBasicSecretName" . }}
key: {{ .Values.auth.multiUserBasic.clientSecretKey }}
{{- end }}
{{- end }}
{{- else if eq .Values.auth.mode "oauth" }}
# OAuth mode
- name: NEXTCLOUD_MCP_SERVER_URL
@@ -124,7 +87,7 @@ spec:
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.oauth.scopes | quote }}
{{- if or .Values.auth.oauth.clientId .Values.auth.oauth.existingSecret }}
{{- if .Values.auth.oauth.clientId }}
- name: NEXTCLOUD_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
@@ -136,21 +99,6 @@ spec:
name: {{ include "nextcloud-mcp-server.oauthSecretName" . }}
key: {{ .Values.auth.oauth.clientSecretKey }}
{{- end }}
{{- else if eq .Values.auth.mode "login-flow" }}
# Login Flow v2 mode (ADR-022)
- name: ENABLE_LOGIN_FLOW
value: "true"
- name: NEXTCLOUD_MCP_SERVER_URL
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: TOKEN_STORAGE_DB
value: {{ .Values.auth.loginFlow.tokenStorageDb | quote }}
- name: TOKEN_ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: {{ include "nextcloud-mcp-server.loginFlowSecretName" . }}
key: {{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}
{{- end }}
{{- if .Values.documentProcessing.enabled }}
# Document processing
@@ -199,16 +147,16 @@ spec:
value: {{ .Values.documentProcessing.custom.types | quote }}
{{- end }}
{{- end }}
# Semantic Search (replaces deprecated VECTOR_SYNC_ENABLED)
- name: ENABLE_SEMANTIC_SEARCH
value: {{ .Values.semanticSearch.enabled | quote }}
{{- if .Values.semanticSearch.enabled }}
# Vector Sync
- name: VECTOR_SYNC_ENABLED
value: {{ .Values.vectorSync.enabled | quote }}
{{- if .Values.vectorSync.enabled }}
- name: VECTOR_SYNC_SCAN_INTERVAL
value: {{ .Values.semanticSearch.scanInterval | quote }}
value: {{ .Values.vectorSync.scanInterval | quote }}
- name: VECTOR_SYNC_PROCESSOR_WORKERS
value: {{ .Values.semanticSearch.processorWorkers | quote }}
value: {{ .Values.vectorSync.processorWorkers | quote }}
- name: VECTOR_SYNC_QUEUE_MAX_SIZE
value: {{ .Values.semanticSearch.queueMaxSize | quote }}
value: {{ .Values.vectorSync.queueMaxSize | quote }}
{{- end }}
# Document Chunking (always set, used by vector sync processor)
- name: DOCUMENT_CHUNK_SIZE
@@ -299,29 +247,29 @@ spec:
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
- name: oauth-storage
mountPath: /app/.oauth
{{- end }}
- name: data-storage
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
mountPath: /app/data
{{- end }}
{{- with .Values.volumeMounts }}
{{- toYaml . | nindent 12 }}
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- if or (and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled) (eq .Values.auth.mode "login-flow") }}
{{- if and (eq .Values.auth.mode "oauth") .Values.auth.oauth.persistence.enabled }}
- name: oauth-storage
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.oauthPvcName" . }}
{{- end }}
- name: data-storage
{{- if eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true" }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled }}
- name: qdrant-data
persistentVolumeClaim:
claimName: {{ include "nextcloud-mcp-server.dataStoragePvcName" . }}
{{- else }}
emptyDir: {}
claimName: {{ include "nextcloud-mcp-server.qdrantPvcName" . }}
{{- end }}
{{- with .Values.volumes }}
{{- toYaml . | nindent 8 }}
+6 -35
View File
@@ -16,49 +16,20 @@ spec:
storage: {{ .Values.auth.oauth.persistence.size }}
{{- end }}
---
{{- if eq .Values.auth.mode "login-flow" }}
{{- if and (eq .Values.qdrant.mode "persistent") .Values.qdrant.localPersistence.enabled (not .Values.qdrant.localPersistence.existingClaim) }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-oauth-storage
name: {{ include "nextcloud-mcp-server.fullname" . }}-qdrant-data
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 100Mi
{{- end }}
---
{{- if and (eq (include "nextcloud-mcp-server.dataStorageEnabled" .) "true") (not .Values.dataStorage.existingClaim) }}
{{- $legacyMultiUserBasic := eq (include "nextcloud-mcp-server.legacyMultiUserBasicPersistence" .) "true" }}
{{- $legacyQdrant := eq (include "nextcloud-mcp-server.legacyQdrantPersistence" .) "true" }}
{{- $accessMode := .Values.dataStorage.accessMode }}
{{- $storageClass := .Values.dataStorage.storageClass }}
{{- $size := .Values.dataStorage.size }}
{{- if $legacyMultiUserBasic }}
{{- $accessMode = .Values.auth.multiUserBasic.persistence.accessMode }}
{{- $storageClass = .Values.auth.multiUserBasic.persistence.storageClass }}
{{- $size = .Values.auth.multiUserBasic.persistence.size }}
{{- else if $legacyQdrant }}
{{- $accessMode = .Values.qdrant.localPersistence.accessMode }}
{{- $storageClass = .Values.qdrant.localPersistence.storageClass }}
{{- $size = .Values.qdrant.localPersistence.size }}
{{- end }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-data-storage
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
spec:
accessModes:
- {{ $accessMode }}
{{- if $storageClass }}
storageClassName: {{ $storageClass }}
- {{ .Values.qdrant.localPersistence.accessMode }}
{{- if .Values.qdrant.localPersistence.storageClass }}
storageClassName: {{ .Values.qdrant.localPersistence.storageClass }}
{{- end }}
resources:
requests:
storage: {{ $size }}
storage: {{ .Values.qdrant.localPersistence.size }}
{{- end }}
@@ -13,24 +13,6 @@ data:
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "multi-user-basic" }}
{{- if and .Values.auth.multiUserBasic.enableOfflineAccess (not .Values.auth.multiUserBasic.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-multi-user-basic
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.multiUserBasic.tokenEncryptionKeyKey }}: {{ .Values.auth.multiUserBasic.tokenEncryptionKey | b64enc | quote }}
{{- if .Values.auth.multiUserBasic.clientId }}
{{ .Values.auth.multiUserBasic.clientIdKey }}: {{ .Values.auth.multiUserBasic.clientId | b64enc | quote }}
{{ .Values.auth.multiUserBasic.clientSecretKey }}: {{ .Values.auth.multiUserBasic.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "oauth" }}
{{- if and .Values.auth.oauth.clientId (not .Values.auth.oauth.existingSecret) }}
apiVersion: v1
@@ -45,17 +27,3 @@ data:
{{ .Values.auth.oauth.clientSecretKey }}: {{ .Values.auth.oauth.clientSecret | b64enc | quote }}
{{- end }}
{{- end }}
---
{{- if eq .Values.auth.mode "login-flow" }}
{{- if not .Values.auth.loginFlow.existingSecret }}
apiVersion: v1
kind: Secret
metadata:
name: {{ include "nextcloud-mcp-server.fullname" . }}-login-flow
labels:
{{- include "nextcloud-mcp-server.labels" . | nindent 4 }}
type: Opaque
data:
{{ .Values.auth.loginFlow.tokenEncryptionKeyKey }}: {{ .Values.auth.loginFlow.tokenEncryptionKey | b64enc | quote }}
{{- end }}
{{- end }}
+12 -100
View File
@@ -26,30 +26,21 @@ nextcloud:
# Example: https://mcp.example.com
mcpServerUrl: ""
# Public issuer URL for browser-accessible OAuth authorization endpoints (OAuth mode only)
# ONLY used to make authorization endpoints accessible to users' browsers
# All server-to-server communication (token endpoint, JWKS, introspection, userinfo)
# uses URLs from OIDC discovery without any rewriting
#
# Use case: When MCP server accesses Nextcloud at one URL but browsers need a different
# public URL for OAuth login (e.g., server uses internal DNS, browsers use public domain)
#
# If not specified, defaults to nextcloud.host (works when MCP server and browsers
# both access Nextcloud at the same URL)
# Public issuer URL for OAuth (OAuth mode only)
# If not specified, defaults to nextcloud.host
# Only set this if your Nextcloud is accessible at a different URL for OAuth
# Example: https://cloud.example.com
publicIssuerUrl: ""
# Authentication configuration
# Choose one mode: "basic", "multi-user-basic", "oauth", or "login-flow"
# Choose either basic auth OR oauth (not both)
auth:
# Authentication mode: "basic", "multi-user-basic", "oauth", or "login-flow"
# basic: Single-user with username/password (recommended for personal use)
# multi-user-basic: Multi-user with BasicAuth pass-through (credentials in request headers)
# Authentication mode: "basic" or "oauth"
# basic: Uses username/password (recommended for most users)
# oauth: Uses OAuth2/OIDC (experimental, requires patches)
# login-flow: Multi-user via Nextcloud Login Flow v2 (experimental, ADR-022)
mode: basic
# Basic authentication settings (single-user mode)
# Basic authentication settings
basic:
# Nextcloud username (ignored if existingSecret is set)
username: ""
@@ -67,47 +58,6 @@ auth:
usernameKey: "username"
passwordKey: "password"
# Multi-user BasicAuth settings (pass-through mode)
# Users provide credentials in request headers (Authorization: Basic ...)
# Server optionally stores app passwords for background operations
multiUserBasic:
# Enable offline access (background operations using app passwords via Astrolabe)
# When enabled, requires token encryption key. OAuth client credentials are optional (uses DCR if not provided)
enableOfflineAccess: false
# Token encryption key (required if enableOfflineAccess: true, ignored if existingSecret is set)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
tokenEncryptionKey: ""
# Token storage database path
tokenStorageDb: "/app/data/tokens.db"
# OAuth client credentials (optional - uses Dynamic Client Registration if not provided)
# Only needed if enableOfflineAccess: true
clientId: ""
clientSecret: ""
# OAuth scopes to request (space-separated)
scopes: "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
# Use existing secret for multi-user basic auth credentials
# If set, tokenEncryptionKey, clientId, and clientSecret above are ignored
# Secret should contain keys specified in the *Key fields below
# Example:
# kubectl create secret generic my-multiuser-creds \
# --from-literal=token_encryption_key=ESF1BvEQ... \
# --from-literal=client_id=my-client-id \
# --from-literal=client_secret=my-client-secret
existingSecret: ""
# Keys in the existing secret
tokenEncryptionKeyKey: "token_encryption_key"
clientIdKey: "client_id"
clientSecretKey: "client_secret"
# Persistent storage for token database
persistence:
enabled: true
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
size: 100Mi
# Use existing PVC
existingClaim: ""
# OAuth2/OIDC settings (experimental)
oauth:
# OAuth token type: "jwt" or "opaque"
@@ -140,43 +90,6 @@ auth:
# Use existing PVC
existingClaim: ""
# Login Flow v2 settings (experimental, ADR-022)
# Uses Nextcloud's native Login Flow v2 to obtain app passwords per user.
# No OAuth patches required — works with stock Nextcloud.
# See: docs/ADR-022-deployment-mode-consolidation.md
loginFlow:
# Token encryption key (required, ignored if existingSecret is set)
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
tokenEncryptionKey: ""
# Token storage database path
tokenStorageDb: "/app/data/tokens.db"
# Use existing secret instead of creating one
existingSecret: ""
# Key in the existing secret
tokenEncryptionKeyKey: "token_encryption_key"
# Data Storage Configuration
# Persistent volume for /app/data directory
# Used for: token databases, qdrant persistent storage, and any app data
# When disabled, uses emptyDir (non-persistent, but still writable)
dataStorage:
# Enable persistent storage for /app/data
# Set to true when using:
# - Multi-user basic auth with offline access (stores tokens.db)
# - Login flow mode (stores app passwords in tokens.db)
# - Qdrant persistent mode (stores vector database)
# - Any feature requiring persistent app data
# Set to false for basic auth without persistence (uses emptyDir)
enabled: false
# Storage class (leave empty for default)
storageClass: ""
accessMode: ReadWriteOnce
# Size for data storage (should accommodate tokens.db and/or qdrant data)
# Recommended: 1Gi minimum, 5Gi for production with qdrant
size: 1Gi
# Use existing PVC
existingClaim: ""
# MCP server configuration
mcp:
# Transport mode (default: streamable-http for SSE)
@@ -403,11 +316,10 @@ extraEnvFrom: []
# - secretRef:
# name: my-secret
# Semantic Search Configuration
# Enable semantic search with BM25 hybrid search and background synchronization
# of Nextcloud content into vector database
semanticSearch:
# Enable semantic search and background vector synchronization
# Vector Sync Configuration
# Background synchronization of Nextcloud content into vector database for semantic search
vectorSync:
# Enable background vector synchronization
enabled: false
# Scan interval in seconds (how often to check for changes)
scanInterval: 3600
@@ -418,7 +330,7 @@ semanticSearch:
# Document Chunking Configuration
# Controls how documents are split into chunks before embedding
# Only relevant when semanticSearch.enabled is true
# Only relevant when vectorSync.enabled is true
documentChunking:
# Number of words per chunk (default: 512)
# Smaller chunks (256-384): Better for precise searches, more chunks to store
+18 -112
View File
@@ -3,13 +3,11 @@ 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:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
image: docker.io/library/mariadb:lts@sha256:1cac8492bd78b1ec693238dc600be173397efd7b55eabc725abc281dc855b482
restart: always
command: --transaction-isolation=READ-COMMITTED
volumes:
- db:/var/lib/mysql
ports:
- 127.0.0.1:3306:3306
environment:
- MYSQL_ROOT_PASSWORD=password
- MYSQL_PASSWORD=password
@@ -19,25 +17,25 @@ 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:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
image: docker.io/library/redis:alpine@sha256:6cbef353e480a8a6e7f10ec545f13d7d3fa85a212cdcc5ffaf5a1c818b9d3798
restart: always
app:
image: ${NEXTCLOUD_IMAGE:-docker.io/library/nextcloud:32.0.6@sha256:5c4e09f72f096cd68379a8ae69f71e61d13da5a07430fc4a17c702a14e6a4267}
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
restart: always
ports:
- 127.0.0.1:8080:80
- 0.0.0.0:8080:80
depends_on:
- redis
- db
- keycloak
volumes:
- nextcloud:/var/www/html
- ./app-hooks:/docker-entrypoint-hooks.d:ro
# 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
#- ./third_party/oidc:/opt/apps/oidc:ro # Use app store version; dev mount lacks vendor/
#- ./third_party/astrolabe:/opt/apps/astrolabe:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -47,7 +45,6 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
- MCP_SERVER_URL=${MCP_SERVER_URL:-}
healthcheck:
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
interval: 10s
@@ -55,14 +52,14 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:5878d06ae4c83d73285438255f705bb3f9a736f41cd24876ed25bb33faf76c7d
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:ba6cb073af079c498e9466a5a9152ba4b6c9cad12efeeaf053ba383023d5db08
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:54282d3a25f33fd6cf69bc45b3d37770f213593f58b6dfe5e85fe546376b2807
restart: always
ports:
- 127.0.0.1:8002:8000
@@ -89,8 +86,8 @@ services:
- NEXTCLOUD_PASSWORD=admin
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Semantic search configuration (ADR-007, ADR-021)
#- ENABLE_SEMANTIC_SEARCH=true
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
@@ -125,46 +122,6 @@ services:
# Tune these based on your embedding model and content type
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
profiles:
- single-user
mcp-multi-user-basic:
build: .
restart: always
command: ["--transport", "streamable-http"]
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8003:8000
environment:
# Multi-user BasicAuth pass-through mode (ADR-020)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8003
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- ENABLE_MULTI_USER_BASIC_AUTH=true
- ENABLE_BACKGROUND_OPERATIONS=true
# Token storage (required for middleware initialization)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=fqqI4G51yBCOcu9cvv6wCUJB7sf_CK2za5ClC6b86yY=
- TOKEN_STORAGE_DB=/app/data/tokens.db
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# OAuth credentials for background sync (optional - uses DCR if not provided)
# Uncomment to avoid DCR:
# - NEXTCLOUD_OIDC_CLIENT_ID=your_client_id
# - NEXTCLOUD_OIDC_CLIENT_SECRET=your_client_secret
# NO admin credentials - credentials come from client Authorization header
volumes:
- multi-user-basic-data:/app/data
profiles:
- multi-user-basic
mcp-oauth:
build: .
@@ -186,38 +143,31 @@ services:
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# Refresh token storage (ADR-002 Tier 1)
- ENABLE_BACKGROUND_OPERATIONS=true
- TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU=
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false)
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Semantic search configuration (ADR-007, ADR-021)
- ENABLE_SEMANTIC_SEARCH=true
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# Qdrant configuration - persistent local storage
- QDRANT_LOCATION=/app/data/qdrant
# Embedding provider for vector sync (use Simple provider as fallback)
# Ollama not available in CI/test environments
# - OLLAMA_BASE_URL=http://ollama:11434
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# JWT token type is used for testing (faster validation, scopes embedded in token)
volumes:
- oauth-client-storage:/app/.oauth
- oauth-tokens:/app/data
profiles:
- oauth
keycloak:
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
command:
- "start-dev"
- "--import-realm"
@@ -237,8 +187,6 @@ services:
interval: 10s
timeout: 5s
retries: 30
profiles:
- keycloak
mcp-keycloak:
build: .
@@ -267,7 +215,7 @@ services:
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
- ENABLE_BACKGROUND_OPERATIONS=true
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
@@ -284,45 +232,6 @@ services:
volumes:
- keycloak-tokens:/app/data
- keycloak-oauth-storage:/app/.oauth
profiles:
- keycloak
# Login Flow v2 mode (ADR-022)
# Test with: docker compose --profile login-flow up --build -d
mcp-login-flow:
build: .
restart: always
# --oauth enables the OAuth/OIDC identity layer that Login Flow v2 builds on
# (user identity via OAuth session, Nextcloud access via app passwords)
command: ["--transport", "streamable-http", "--oauth", "--port", "8004"]
depends_on:
app:
condition: service_healthy
ports:
- 127.0.0.1:8004:8004
environment:
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8004
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
# Login Flow v2 (ADR-022)
- ENABLE_LOGIN_FLOW=true
# Token storage (required for app password + session persistence)
# DEVELOPMENT ONLY - generate a fresh key for production:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
- TOKEN_ENCRYPTION_KEY=rxJvkBf7ZBjZZDL4a1sSqjhmjawhmbRMSOGfK8HDyKU=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
volumes:
- login-flow-data:/app/data
- login-flow-oauth-storage:/app/.oauth
profiles:
- login-flow
# Smithery stateless deployment mode (ADR-016)
# Test with: docker compose --profile smithery up smithery
@@ -339,13 +248,13 @@ services:
- 127.0.0.1:8081:8081
environment:
- SMITHERY_DEPLOYMENT=true
- ENABLE_SEMANTIC_SEARCH=false
- VECTOR_SYNC_ENABLED=false
- PORT=8081
profiles:
- smithery
qdrant:
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
@@ -369,8 +278,5 @@ volumes:
oauth-tokens:
keycloak-tokens:
keycloak-oauth-storage:
login-flow-data:
login-flow-oauth-storage:
qdrant-data:
mcp-data:
multi-user-basic-data:
@@ -1,342 +0,0 @@
# ADR-020: Deployment Modes and Configuration Validation
**Status:** Accepted
**Date:** 2025-12-20
**Deciders:** Development Team
**Related:** ADR-002 (Vector Sync), ADR-004 (Progressive Consent), ADR-019 (Multi-user BasicAuth)
## Context
The MCP server supports multiple deployment scenarios with different authentication methods, storage backends, and feature sets. Over time, the configuration system evolved to support ~500+ possible combinations across deployment modes, authentication patterns, and feature toggles. This complexity made it difficult to:
1. Understand what configuration is required for a given deployment
2. Debug configuration errors (validation scattered across multiple files)
3. Provide helpful error messages when configuration is invalid
4. Maintain clear boundaries between deployment modes
**Problems Identified:**
- No single source of truth for "what config is required for mode X"
- Validation happening at 4+ different points (Settings.__post_init__, setup_oauth_config(), context helpers, starlette_lifespan)
- Startup sequence unclear (OAuth setup before FastMCP creation, sync initialization errors)
- Error messages generic ("X is required") without explaining which deployment mode triggered the requirement
- Multiple overlapping decision trees (deployment mode, auth mode, features)
## Decision
We formalize five distinct deployment modes with explicit configuration requirements and implement centralized configuration validation.
### Deployment Modes
#### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password # Or app password
```
**Optional Configuration:**
```bash
# Vector sync (semantic search)
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=/path/to/qdrant # Or QDRANT_URL for remote
# Embeddings (optional - Simple provider used as fallback)
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Document processing
DOCUMENT_CHUNK_SIZE=512
DOCUMENT_CHUNK_OVERLAP=50
```
**Characteristics:**
- Single shared NextcloudClient created at startup
- No OAuth infrastructure needed
- No multi-user support
- Vector sync runs as single-user background task
- Admin UI available at /app
---
#### 2. Multi-User BasicAuth Pass-Through
**Use Case:** Internal deployment where users provide their own credentials, no background sync needed
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
```
**Optional Configuration:**
```bash
# For background sync (requires app passwords from Astrolabe)
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- No OAuth for client authentication (uses BasicAuth in request headers)
- BasicAuthMiddleware extracts credentials from Authorization header
- Client created per-request from extracted credentials
- Optional: Background sync using app passwords (via Astrolabe API)
- Admin UI available at /app
---
#### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication, tokens work for both MCP and Nextcloud
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Auto-Configured:**
- OIDC discovery URL: `{NEXTCLOUD_HOST}/.well-known/openid-configuration`
- Client credentials: Dynamic Client Registration (DCR) if available
- Token storage: SQLite at `~/.oauth/clients.db`
**Optional Configuration:**
```bash
# Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Offline access for background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
VECTOR_SYNC_ENABLED=true
# ... plus Qdrant and embedding config
# Scopes
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write ..."
```
**Conditional Requirements:**
- If `ENABLE_OFFLINE_ACCESS=true`: requires `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
**Characteristics:**
- Tokens contain both `aud: ["mcp-server", "nextcloud"]`
- Pass token through to Nextcloud APIs (no exchange)
- Client created per-request from token in Authorization header
- Background sync uses refresh tokens (if offline_access enabled)
- Admin UI available at /app
---
#### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP token is separate from Nextcloud token
**Required Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
```
**Optional Configuration:**
- Same as OAuth Single-Audience, plus:
```bash
TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens
```
**Characteristics:**
- Tokens contain only `aud: "mcp-server"`
- MCP server exchanges token for Nextcloud token via RFC 8693
- Exchanged tokens cached per-user
- Client created per-request using exchanged token
- Background sync uses refresh tokens (if offline_access enabled)
---
#### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform
**Required Configuration:**
- None! Configuration comes from session URL params: `?nextcloud_url=...&username=...&app_password=...`
**Forbidden Configuration:**
- Must NOT set: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`, `ENABLE_MULTI_USER_BASIC_AUTH`, `ENABLE_TOKEN_EXCHANGE`, `ENABLE_OFFLINE_ACCESS`, `VECTOR_SYNC_ENABLED`, `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`
**Characteristics:**
- No persistent storage (stateless)
- Client created per-request from session config
- No vector sync (disabled)
- No admin UI (no /app routes)
- No OAuth infrastructure
---
### Configuration Validation
**Implementation:** `nextcloud_mcp_server/config_validators.py`
**Key Functions:**
```python
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Priority (most specific to most general):
1. Smithery (explicit flag)
2. Token exchange (most specific OAuth mode)
3. Multi-user BasicAuth
4. Single-user BasicAuth
5. OAuth single-audience (default OAuth mode)
"""
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
```
**Validation Rules:**
- **Required variables:** Must be set and non-empty
- **Forbidden variables:** Must NOT be set (or must be False for booleans)
- **Conditional requirements:** If feature X is enabled, requires variables Y and Z
**Error Messages:**
```
Configuration validation failed for {mode} mode:
- [{mode}] Missing required configuration: NEXTCLOUD_HOST
- [{mode}] ENABLE_OFFLINE_ACCESS must be enabled when VECTOR_SYNC_ENABLED is true
Mode: {mode}
Description: {mode_description}
Required configuration:
- VAR1
- VAR2
Optional configuration:
- VAR3
- VAR4
Conditional requirements:
When FEATURE is enabled:
- VAR5
- VAR6
```
**Integration:**
- Validation runs at app startup in `get_app()` (app.py:1048-1062)
- All errors reported before any initialization begins
- Mode-specific error messages explain requirements
- Validation uses the same Settings object used throughout the app
### Configuration Matrix
| Variable | Single BasicAuth | Multi BasicAuth | OAuth Single | OAuth Exchange | Smithery |
|----------|------------------|-----------------|--------------|----------------|----------|
| **NEXTCLOUD_HOST** | Required | Required | Required | Required | Forbidden |
| **NEXTCLOUD_USERNAME** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **NEXTCLOUD_PASSWORD** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
| **ENABLE_MULTI_USER_BASIC_AUTH** | Forbidden | Required | Forbidden | Forbidden | Forbidden |
| **ENABLE_TOKEN_EXCHANGE** | Forbidden | Forbidden | Forbidden | Required | Forbidden |
| **ENABLE_OFFLINE_ACCESS** | Optional\* | Optional\* | Optional\* | Optional\* | Forbidden |
| **TOKEN_ENCRYPTION_KEY** | If offline | If offline | If offline | If offline | Forbidden |
| **TOKEN_STORAGE_DB** | If offline | If offline | If offline | If offline | Forbidden |
| **OIDC_CLIENT_ID** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **OIDC_CLIENT_SECRET** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
| **VECTOR_SYNC_ENABLED** | Optional | Optional | Optional | Optional | Forbidden |
| **QDRANT_URL/LOCATION** | If vector | If vector | If vector | If vector | Forbidden |
| **OLLAMA_BASE_URL/OPENAI_API_KEY** | Optional | Optional | Optional | Optional | Forbidden |
\* Only enables background sync for semantic search
\*\* Uses DCR if not provided
## Consequences
### Positive
1. **Clarity:** Single function to detect mode from config
2. **Validation:** All config validated upfront with helpful errors
3. **Debugging:** Clear logs showing "Running in X mode with config Y"
4. **Maintenance:** Mode-specific logic can be isolated
5. **Documentation:** Clear mapping of mode → required config
6. **Error Messages:** Context-aware ("X is required for Y mode")
7. **Testing:** Each mode testable in isolation
### Negative
1. **Migration:** Existing invalid configurations will now fail at startup
2. **Flexibility:** Less flexibility in configuration combinations
3. **Strictness:** Some previously-working combinations may be rejected
### Neutral
1. **Backward Compatibility:** Valid configurations continue to work
2. **Mode Detection:** Automatic based on config (no explicit mode selection)
3. **Default Mode:** OAuth single-audience when no credentials provided
## Implementation Notes
### Embedding Provider Validation
Originally, validation required either `OLLAMA_BASE_URL` or `OPENAI_API_KEY` when vector sync was enabled. This was too strict because the Simple provider is always available as a fallback (ADR-015). The validation was removed to allow vector sync without explicit provider configuration.
### Variable Scoping Issues
During implementation, several Python variable scoping issues were discovered in `app.py`:
- Local variable assignments in `starlette_lifespan()` shadowed outer scope variables
- Fixed by using unique variable names (e.g., `nextcloud_host_for_context`, `basic_auth_storage`)
- Removed redundant `settings = get_settings()` call (re-used outer scope)
### Docker Compose Configuration
The `mcp-oauth` service configuration was updated to remove `ENABLE_MULTI_USER_BASIC_AUTH=true` which conflicted with its intended OAuth mode. The service now runs in OAuth single-audience mode with vector sync using the Simple embedding provider as fallback.
## Testing
### Unit Tests
`tests/unit/test_config_validators.py` provides comprehensive coverage:
- Mode detection with priority ordering (7 tests)
- Single-user BasicAuth validation (8 tests)
- Multi-user BasicAuth validation (7 tests)
- OAuth single-audience validation (6 tests)
- OAuth token exchange validation (3 tests)
- Smithery validation (4 tests)
- Mode summary generation (3 tests)
- Edge cases (3 tests)
**Total: 41 tests, all passing**
### Integration Tests
Integration tests verify that:
- Each mode starts successfully with valid configuration
- Invalid configurations fail with clear error messages
- Existing deployments continue to work
## References
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-progressive-consent.md)
- [ADR-015: Unified Provider Architecture](ADR-015-unified-provider-architecture.md)
- [ADR-019: Multi-user BasicAuth Pass-Through](ADR-019-multi-user-basicauth-passthrough.md)
- Implementation: `nextcloud_mcp_server/config_validators.py`
- Tests: `tests/unit/test_config_validators.py`
-391
View File
@@ -1,391 +0,0 @@
# ADR-021: Configuration Consolidation and Simplification
**Status:** Accepted
**Date:** 2025-12-21
**Deciders:** Development Team
**Related:** ADR-020 (Deployment Modes), ADR-002 (Vector Sync), ADR-004 (Progressive Consent)
## Context
The configuration system has grown complex with overlapping concerns that make it difficult for users to switch between deployment modes and understand configuration dependencies.
### Problems Identified
1. **Confusing variable names don't reflect purpose**:
- `ENABLE_OFFLINE_ACCESS` - Actually controls refresh token storage for background operations, not general "offline" capabilities
- `VECTOR_SYNC_ENABLED` - Controls semantic search background indexing (implementation detail, not user-facing feature name)
- Users struggle to understand what these variables actually control
2. **Redundant configuration requirements**:
- Multi-user semantic search requires setting BOTH `ENABLE_OFFLINE_ACCESS=true` AND `VECTOR_SYNC_ENABLED=true`
- The dependency is one-way (semantic search needs background ops, but background ops don't need semantic search)
- Users must understand internal implementation details to configure a user-facing feature
3. **Implicit mode detection creates ambiguity**:
- Five deployment modes detected via priority-based logic
- Users can't easily predict which mode will activate
- Configuration errors don't clearly indicate which mode triggered the requirement
4. **OIDC_CLIENT_ID vs NEXTCLOUD_OIDC_CLIENT_ID confusion**:
- Investigation revealed these are NOT actually overlapping (`OIDC_CLIENT_ID` is test-only)
- However, their similar names create confusion
### Current Configuration Complexity
**Example: Multi-user OAuth with semantic search**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Why is this needed?
VECTOR_SYNC_ENABLED=true # And this separately?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
Users must understand:
- Semantic search requires background token storage (ENABLE_OFFLINE_ACCESS)
- Background token storage requires encryption keys
- The relationship between ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED
- Which deployment mode these settings will activate
## Decision
We consolidate overlapping functionality and add explicit mode selection while maintaining 100% backward compatibility.
### 1. Automatic Dependency Resolution
**Make ENABLE_SEMANTIC_SEARCH the primary control** that automatically enables required dependencies:
**New behavior**:
```python
@property
def enable_background_operations(self) -> bool:
"""Background operations - auto-enabled by semantic search in multi-user modes."""
# Check new names first
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
# Fall back to old name with deprecation warning
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
# Auto-enable if semantic search needs it
auto_enabled = self.enable_semantic_search and self.is_multi_user_mode()
return explicit or legacy or auto_enabled
@property
def enable_semantic_search(self) -> bool:
"""Semantic search - renamed from VECTOR_SYNC_ENABLED."""
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
return new_value or old_value
```
**Result**: Users set `ENABLE_SEMANTIC_SEARCH=true` and the system automatically enables background token storage when needed.
### 2. Explicit Mode Selection (Optional)
Add `MCP_DEPLOYMENT_MODE` environment variable to remove detection ambiguity:
```bash
# Optional: Explicitly declare deployment mode
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Valid values: single_user_basic, multi_user_basic,
# oauth_single_audience, oauth_token_exchange, smithery
```
**Detection logic**:
1. If `MCP_DEPLOYMENT_MODE` is set → validate and use it
2. Otherwise → use priority-based auto-detection (existing behavior)
3. Validate explicit mode doesn't conflict with detected mode
### 3. Simplified User Experience
**Before**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # Confusing
VECTOR_SYNC_ENABLED=true # Why both?
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After**:
```bash
# Multi-user OAuth with semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background ops
QDRANT_URL=http://qdrant:6333
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**Benefits**:
- 2 fewer variables to understand/set
- Clear intent ("I want semantic search")
- Explicit mode declaration (optional)
- All existing configs continue working
### 4. Variable Naming Strategy
**Deprecated (but still functional)**:
- `ENABLE_OFFLINE_ACCESS` → Renamed to `ENABLE_BACKGROUND_OPERATIONS`
- `VECTOR_SYNC_ENABLED` → Renamed to `ENABLE_SEMANTIC_SEARCH`
**No change needed**:
- `VECTOR_SYNC_SCAN_INTERVAL` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Implementation tuning parameter (keep as-is)
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Implementation tuning parameter (keep as-is)
**Rationale**: Only rename user-facing feature flags, not internal tuning parameters.
### 5. Backward Compatibility
**Support both old and new names for minimum 2 major versions**:
```python
@property
def enable_semantic_search(self) -> bool:
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
if new_value and old_value:
logger.warning(
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
"Using ENABLE_SEMANTIC_SEARCH. VECTOR_SYNC_ENABLED is deprecated."
)
if old_value and not new_value:
logger.warning(
"VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead."
)
return new_value or old_value
```
**Deprecation timeline**:
- v0.6.0: Add new variables, deprecate old ones (both work with warnings)
- v1.0.0: Remove old variables (breaking change, well-announced)
- Minimum 2 major versions of support (12+ months)
## Consequences
### Positive
1. **Reduced cognitive load**: Users set `ENABLE_SEMANTIC_SEARCH=true` instead of understanding internal dependencies
2. **Clearer intent**: Variable names reflect user-facing features, not implementation details
3. **Explicit mode control**: `MCP_DEPLOYMENT_MODE` removes detection ambiguity
4. **Better onboarding**: New users see simpler configuration in env.sample
5. **Improved error messages**: Validation can suggest "set MCP_DEPLOYMENT_MODE=X" instead of relying on implicit detection
6. **No breaking changes**: All existing configurations continue working
### Negative
1. **Transition period complexity**: Both old and new names supported for 2+ versions
2. **Documentation burden**: All docs must be updated to show new approach
3. **Test coverage expansion**: Must test both old and new variable names in all modes
4. **Migration effort**: Existing deployments should eventually migrate (optional but recommended)
### Neutral
1. **Same functionality**: No new features, just better organization
2. **Same validation**: Underlying requirements unchanged (e.g., semantic search still needs Qdrant)
3. **Same performance**: No runtime performance impact
## Implementation
### Phase 1: Configuration Consolidation (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add property-based deprecation with auto-enablement
- `nextcloud_mcp_server/config_validators.py` - Simplify validation (semantic search no longer requires explicit background operations setting)
- `nextcloud_mcp_server/app.py` - Add informative logging for auto-enablement
- `tests/unit/test_config_validators.py` - Add auto-enablement tests
- `docs/configuration-migration-v2.md` - Create migration guide
**Key changes**:
1. `enable_background_operations` property auto-enables when `enable_semantic_search=true` in multi-user modes
2. `enable_semantic_search` property accepts both `ENABLE_SEMANTIC_SEARCH` and `VECTOR_SYNC_ENABLED`
3. Smart logging when auto-enablement occurs or deprecated variables used
4. Validation simplified to remove redundant requirements
### Phase 2: Explicit Mode Selection (v0.6.0)
**Files to modify**:
- `nextcloud_mcp_server/config.py` - Add `deployment_mode` field
- `nextcloud_mcp_server/config_validators.py` - Check explicit mode first, fall back to auto-detection
- `tests/unit/test_config_validators.py` - Test mode override and conflict detection
- `docs/configuration.md` - Document mode selection
**Key changes**:
1. Add `MCP_DEPLOYMENT_MODE` environment variable (optional)
2. Mode detection checks explicit mode first, then auto-detects
3. Validate explicit mode doesn't conflict with detected mode
4. Better error messages referencing explicit mode setting
### Phase 3: env.sample Reorganization (v0.6.0)
**Files to create/modify**:
- `env.sample` - Reorganize by deployment mode
- `env.sample.single-user` - Simplest config template
- `env.sample.oauth-multi-user` - Multi-user template showing consolidation
- `env.sample.oauth-advanced` - Token exchange mode template
- `README.md` - Update Quick Start to reference templates
**Key changes**:
1. Group related settings by deployment mode
2. Show simplified configuration (only essential variables)
3. Document automatic dependencies inline
4. Provide mode-specific quick-start templates
### Phase 4: Documentation Updates (v0.7.0)
**Files to modify**:
- `docs/configuration.md` - Lead with consolidated approach
- `docs/authentication.md` - Update mode guidance with `MCP_DEPLOYMENT_MODE`
- `docs/troubleshooting.md` - Add consolidation troubleshooting section
- `docs/configuration-migration-v2.md` - Expand with comprehensive examples
- `docs/ADR-020-deployment-modes-and-configuration-validation.md` - Update configuration matrix
- All other ADRs - Update variable references
**Key changes**:
1. Update all examples to use new variable names
2. Add before/after migration examples
3. Document automatic dependency resolution
4. Add mode selection decision tree diagram
## Validation Strategy
### Test Coverage Requirements
**Backward compatibility tests**:
- Old variable names still work (ENABLE_OFFLINE_ACCESS, VECTOR_SYNC_ENABLED)
- New variable names work (ENABLE_BACKGROUND_OPERATIONS, ENABLE_SEMANTIC_SEARCH)
- Setting both old and new triggers deprecation warning but works correctly
- All 41 existing config validation tests pass
**Auto-enablement tests**:
- `ENABLE_SEMANTIC_SEARCH=true` in OAuth mode → `enable_background_operations=true`
- `ENABLE_SEMANTIC_SEARCH=true` in single-user mode → `enable_background_operations=false` (not needed)
- `ENABLE_SEMANTIC_SEARCH=false``enable_background_operations=false` (unless explicitly set)
**Mode selection tests**:
- `MCP_DEPLOYMENT_MODE=oauth_single_audience` → mode correctly detected
- `MCP_DEPLOYMENT_MODE` conflicts with detected mode → validation error
- No `MCP_DEPLOYMENT_MODE` → auto-detection works as before
## Success Metrics
**Immediate** (v0.6.0 release):
- Zero breaking changes in existing deployments
- All 41 config validation tests pass
- New users report clearer configuration process
**Medium-term** (6 months after v0.6.0):
- 80% of new deployments use new variable names
- Mode selection errors decrease by 50%
- Support requests about configuration decrease
**Long-term** (12+ months):
- 90% of deployments migrated to new names
- Old variable names can be safely removed in v1.0.0
- Configuration-related issues in issue tracker decrease
## Alternatives Considered
### Alternative 1: Just Rename Variables
**Rejected**: User feedback: "There's no reason to just rename variables without consolidating functionality"
This would make names clearer but wouldn't reduce the number of variables users need to set. The real problem is requiring users to set both ENABLE_OFFLINE_ACCESS and VECTOR_SYNC_ENABLED when they just want semantic search.
### Alternative 2: Remove ENABLE_OFFLINE_ACCESS Entirely
**Rejected**: Advanced users need background operations without semantic search
Some deployments might want background token storage for future features (background Deck sync, background Calendar sync, etc.) without enabling semantic search. Keeping ENABLE_BACKGROUND_OPERATIONS (renamed) allows this.
### Alternative 3: Always Auto-Enable Background Operations
**Rejected**: Single-user mode doesn't need background token storage
Auto-enablement is only needed in multi-user modes. Single-user mode uses a shared client with BasicAuth, so background token storage is unnecessary. Always enabling it would waste resources and create confusing log messages.
### Alternative 4: Require All New Names Immediately
**Rejected**: Breaking change would affect all existing deployments
Forcing migration to new variable names in v0.6.0 would break every existing deployment. Supporting both old and new names with deprecation warnings provides a smooth migration path.
## References
- [ADR-020: Deployment Modes and Configuration Validation](ADR-020-deployment-modes-and-configuration-validation.md)
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md)
- [Issue: Configuration complexity for multi-user semantic search](https://github.com/cbcoutinho/nextcloud-mcp-server/issues/XXX)
## Migration Examples
### Example 1: Single-User BasicAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
ENABLE_SEMANTIC_SEARCH=true # Renamed
QDRANT_LOCATION=:memory:
# Note: Background operations NOT auto-enabled (not needed in single-user mode)
```
### Example 2: Multi-User OAuth with Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
```
**After** (simplified):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience # Explicit (optional)
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
QDRANT_URL=http://qdrant:6333
# Note: ENABLE_OFFLINE_ACCESS no longer needed (auto-enabled)
```
### Example 3: Multi-User OAuth WITHOUT Semantic Search
**Before**:
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_OFFLINE_ACCESS=true # For future background features
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
**After** (optional migration):
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
ENABLE_BACKGROUND_OPERATIONS=true # Renamed for clarity
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/path/to/tokens.db
```
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
# ADR-023: OAuth Authorization Server Proxy
## Status
Accepted
## Date
2026-03-02
## Context
When the MCP server operates in OAuth mode (e.g., `mcp-login-flow` profile), MCP clients like Claude Code need to authenticate before calling any tools. The server advertises itself as an OAuth Protected Resource via RFC 9728 (Protected Resource Metadata / PRM), which tells clients where to find the Authorization Server.
### The Problem
The original design used a **pass-through** pattern for Flow 1 (client authentication):
1. PRM at `/.well-known/oauth-protected-resource` pointed `authorization_servers` to Nextcloud's public URL
2. Claude Code performed OIDC discovery on Nextcloud, used DCR to register its own client, and obtained tokens directly from Nextcloud
3. Tokens issued by Nextcloud had Claude Code's `client_id` as the `aud` (audience) claim
This caused an audience mismatch:
```
Token rejected: Missing MCP audience.
Got klehQp8uHCK9fu... (Claude Code's client_id),
need 8ilzB5ZPWr2Qt4... (MCP server's client_id) or http://localhost:8004
```
The `_has_mcp_audience()` check in `unified_verifier.py` correctly requires tokens to contain either the MCP server's `client_id` or its URL as the audience — but tokens obtained directly from Nextcloud by a third-party client will never have that audience.
This meant Claude Code could never authenticate → could never call `nc_auth_provision_access` → Login Flow v2 never triggered → the server was unusable.
### Why Not Just Relax Audience Validation?
Audience validation exists for security (RFC 7519 §4.1.3). Removing it would allow any valid Nextcloud token to access the MCP server, including tokens issued for completely different purposes.
## Decision
Make the MCP server act as its own **OAuth Authorization Server proxy** (intermediary pattern). The MCP server advertises itself as the AS, handles client registration and authorization, but proxies the actual authentication to Nextcloud using its own credentials. This ensures all tokens have the correct audience.
### Flow Overview
```
Client MCP Server (AS Proxy) Nextcloud (IdP)
| | |
|-- POST /oauth/register ----->| ---- proxy DCR --------------->|
|<---- client_id, etc. --------|<---- client_id, etc. ----------|
| | |
|-- GET /oauth/authorize ----->| (store client params) |
| (client_id, redirect, | redirect with MCP's client_id |
| code_challenge, state) |------- GET /authorize -------->|
| | (MCP client_id, MCP callback) |
| | |
| | [user authenticates] |
| | |
| |<------ code + state -----------|
| | (exchange code server-side) |
| |------- POST /token ----------->|
| | (code, MCP client_id+secret) |
| |<------ NC token (aud=MCP) -----|
| | |
| | (generate proxy_code, store |
| | mapping to NC token) |
|<-- redirect to client -------| |
| (proxy_code, state) | |
| | |
|-- POST /oauth/token -------->| (verify PKCE, lookup code) |
| (proxy_code, code_verifier) | return stored NC token |
|<---- access_token -----------| |
| | |
|-- POST /mcp (Bearer token) ->| verify_access_token() |
| (NC token with aud=MCP ✓) | _has_mcp_audience() → PASS |
```
### Key Design Decisions
#### 1. PKCE Handling — Local Verification
The MCP server receives the client's `code_challenge` but does **not** forward it to Nextcloud. Instead:
- **Nextcloud side**: MCP server authenticates as a confidential client (`client_id` + `client_secret`), so PKCE is not required
- **Client side**: MCP server verifies PKCE locally when the client exchanges the proxy code at `/oauth/token`
This avoids the impossible situation where the server would need the `code_verifier` to exchange code with Nextcloud but doesn't have it (only the client does).
#### 2. In-Memory Proxy Code Storage
Proxy codes (the authorization codes issued by the AS proxy to clients) use in-memory storage rather than SQLite because:
- They have a 60-second TTL
- They are single-use (deleted on exchange)
- They only exist during the brief OAuth flow
- The MCP server is single-instance
#### 3. PRM Points to MCP Server
The `authorization_servers` field in the PRM response now points to the MCP server URL instead of Nextcloud's public URL. This is what triggers the entire proxy flow — clients discover the MCP server as their AS.
#### 4. DCR Proxy
Client registration requests at `/oauth/register` are proxied to Nextcloud's DCR endpoint. The resulting `client_id` is stored in the local `ClientRegistry` so that `/oauth/authorize` can validate it. The client receives the same DCR response it would get from Nextcloud directly.
## Alternatives Considered
### 1. Relax Audience Validation
Remove `_has_mcp_audience()` check entirely. **Rejected**: Violates RFC 7519 security model.
### 2. Client Pre-Registration
Require clients to register directly with Nextcloud and configure the MCP server with their `client_id`. **Rejected**: Poor UX, doesn't work with DCR-based clients like Claude Code.
### 3. Token Exchange (RFC 8693)
The MCP server could accept any Nextcloud token and exchange it for one with the correct audience. **Rejected**: Nextcloud's OIDC app doesn't support RFC 8693 token exchange. This was already explored in ADR-005.
### 4. Custom Audience Configuration
Add configuration to accept specific external `client_id` values as valid audiences. **Rejected**: Requires manual configuration per client, doesn't scale with DCR.
## New Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/.well-known/oauth-authorization-server` | GET | RFC 8414 AS metadata |
| `/oauth/authorize` | GET | Authorization (modified: intermediary, not pass-through) |
| `/oauth/token` | POST | Token exchange (proxy codes + refresh token proxy) |
| `/oauth/register` | POST | DCR proxy to Nextcloud |
## Files Modified
| File | Changes |
|------|---------|
| `nextcloud_mcp_server/auth/oauth_routes.py` | New: `oauth_as_metadata`, `oauth_register_proxy`, `oauth_token_endpoint`, `_oauth_callback_as_proxy`. Modified: `oauth_authorize` (intermediary pattern), `oauth_callback` (AS proxy routing) |
| `nextcloud_mcp_server/app.py` | New routes, PRM `authorization_servers` → MCP server URL, `app.state.supported_scopes` |
| `nextcloud_mcp_server/auth/client_registry.py` | New: `register_proxy_client()`, wildcard scope support |
## Consequences
### Positive
- Tokens always have the correct audience — `_has_mcp_audience()` passes
- Works with any MCP client that implements RFC 9728 (PRM) discovery
- No changes needed to Nextcloud's OIDC configuration
- DCR still works transparently (clients register via proxy)
- Existing Flow 2 (resource provisioning) and browser login are unaffected
### Negative
- MCP server is now stateful during the OAuth flow (in-memory proxy codes)
- Extra network hop for token exchange (MCP server → Nextcloud → back)
- Token refresh requires proxying through the MCP server
- Single-instance limitation for proxy code storage (acceptable for current deployment model)
### Risks
- In-memory proxy codes are lost on server restart (mitigated by 60s TTL — user just retries)
- Discovery endpoint fetch during OAuth flow adds latency (could be cached)
## References
- [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://tools.ietf.org/html/rfc8414)
- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://tools.ietf.org/html/rfc9728)
- [RFC 7636 — PKCE](https://tools.ietf.org/html/rfc7636)
- [RFC 7591 — Dynamic Client Registration](https://tools.ietf.org/html/rfc7591)
- ADR-004 — MCP Application OAuth (progressive consent architecture)
- ADR-005 — Token Audience Validation
-422
View File
@@ -1,422 +0,0 @@
# Authentication Flows by Deployment Mode
This document provides a unified reference for authentication flows across all deployment modes. For configuration details, see [Authentication](authentication.md). For OAuth protocol details, see [OAuth Architecture](oauth-architecture.md).
## Quick Reference Matrix
| Mode | Client → MCP → NC | Background Sync | Astrolabe → MCP |
|------|-------------------|-----------------|-----------------|
| [Single-User BasicAuth](#1-single-user-basicauth) | Embedded credentials | Same credentials | N/A |
| [Multi-User BasicAuth](#2-multi-user-basicauth) | Header pass-through | App password (optional) | Bearer token |
| [OAuth Single-Audience](#3-oauth-single-audience-default) | Multi-audience token | Refresh token exchange | Bearer token |
| [OAuth Token Exchange](#4-oauth-token-exchange-rfc-8693) | RFC 8693 exchange | Refresh token exchange | Bearer token |
| [Smithery Stateless](#5-smithery-stateless) | Session parameters | Not supported | N/A |
## Communication Patterns
This document covers three distinct communication patterns:
1. **MCP Client → MCP Server → Nextcloud**: Interactive tool calls initiated by users through MCP clients (Claude Desktop, etc.)
2. **MCP Server → Nextcloud**: Background operations like vector sync that run without user interaction
3. **Astrolabe → MCP Server**: Nextcloud app backend communication for settings UI and unified search
---
## Deployment Modes
### 1. Single-User BasicAuth
**Use Case:** Personal Nextcloud instance, local development, single-user deployments.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ (no auth required) │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ Authorization: Basic │
│ │ (embedded credentials) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Credentials embedded in server configuration (`NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`)
- Single shared `NextcloudClient` created at startup
- No MCP-level authentication required (server trusts local clients)
- All requests use the same Nextcloud user
**Implementation:** `context.py:78-79` - Returns shared client from lifespan context
#### Background Sync
Uses the same embedded credentials as interactive requests. The background job accesses Nextcloud with the configured username/password.
**Implementation:** Background jobs use `get_settings()` to access credentials
#### Astrolabe Integration
Not applicable - Astrolabe is only used in multi-user deployments where users need personal settings and token management.
---
### 2. Multi-User BasicAuth
**Use Case:** Internal deployment where users provide their own credentials via HTTP headers.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── MCP Request ─────────────▶│ │
│ Authorization: Basic │ │
│ (user credentials) │ │
│ │── BasicAuthMiddleware ────▶│
│ │ Extracts credentials │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (pass-through) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- `BasicAuthMiddleware` extracts credentials from `Authorization: Basic` header
- Credentials passed through to Nextcloud (not stored)
- Client created per-request from extracted credentials
- Stateless - no credential storage between requests
**Implementation:** `context.py:187-248` - `_get_client_from_basic_auth()` extracts credentials from request state
#### Background Sync (Optional)
Requires `ENABLE_OFFLINE_ACCESS=true`. Users can store app passwords via Astrolabe for background operations.
```
Astrolabe MCP Server Nextcloud
│ │ │
│── Store App Password ──────▶│ │
│ (via management API) │ │
│ │── Store in SQLite ────────▶│
│ │ (encrypted) │
│◀── Confirmation ────────────│ │
│ │ │
│ [Background Job] │ │
│ │── Retrieve app password ──▶│
│ │ (from encrypted storage) │
│ │── HTTP + BasicAuth ───────▶│
│ │ (stored app password) │
│ │◀── API Response ───────────│
```
**Requirements:**
- `ENABLE_OFFLINE_ACCESS=true`
- `TOKEN_ENCRYPTION_KEY` for credential encryption
- `TOKEN_STORAGE_DB` for SQLite storage path
#### Astrolabe → MCP Server
```
Astrolabe MCP Server Nextcloud OIDC
│ │ │
│── OAuth Flow ──────────────▶│◀── Token from IdP ────────▶│
│ (user initiates) │ │
│ │ │
│── Bearer Token ────────────▶│ │
│ (management API calls) │ │
│ │── Validate via JWKS ──────▶│
│ │ (or introspection) │
│◀── API Response ────────────│ │
```
**Key characteristics:**
- Astrolabe has its own OAuth client (`astrolabe_client_id` in Nextcloud config)
- Tokens are validated by MCP server using Nextcloud OIDC JWKS
- Authorization check: `token.sub == requested_resource_owner`
- Any valid Nextcloud OIDC token accepted (relaxed audience validation per ADR-018)
**Implementation:** `unified_verifier.py:120-183` - `verify_token_for_management_api()` validates without strict audience check
---
### 3. OAuth Single-Audience (Default)
**Use Case:** Multi-user deployment with OAuth authentication. Tokens work for both MCP and Nextcloud.
This is the default mode when `NEXTCLOUD_USERNAME`/`NEXTCLOUD_PASSWORD` are not set.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: ["mcp-server", │ │
│ "nextcloud"] │ │
│ │── Validate MCP audience ──▶│
│ │ (UnifiedTokenVerifier) │
│ │ │
│ │── HTTP + Same Token ──────▶│
│ │ Authorization: Bearer │
│ │ (multi-audience token) │
│ │ │
│ │ NC validates its own aud │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Token contains both audiences: `aud: ["mcp-server", "nextcloud"]`
- MCP server validates only MCP audience (per RFC 7519)
- Nextcloud independently validates its own audience
- No token exchange needed - same token used throughout
- Stateless operation for interactive requests
**Token validation flow:**
1. `UnifiedTokenVerifier.verify_token()` validates MCP audience
2. Token passed directly to Nextcloud via `get_client_from_context()`
3. Nextcloud validates its own audience when receiving API calls
**Implementation:**
- `unified_verifier.py:185-252` - `_verify_mcp_audience()` validates MCP audience only
- `context.py:96-99` - Uses token directly in multi-audience mode
#### Background Sync
Requires `ENABLE_OFFLINE_ACCESS=true`. Uses stored refresh tokens to obtain access tokens for background operations.
```
MCP Server Nextcloud OIDC
│ │
[Background Job starts] │ │
│── Get refresh token ──────▶│
│ (from encrypted storage) │
│ │
│── Token refresh request ──▶│
│ grant_type=refresh_token │
│ scope=openid profile ... │
│◀── New access + refresh ───│
│ (rotation) │
│ │
│── Store rotated refresh ──▶│
│ (encrypted) │
│ │
│── HTTP + Access Token ────▶│
│ Authorization: Bearer │
│◀── API Response ───────────│
```
**Key characteristics:**
- Refresh tokens stored encrypted in SQLite (`TOKEN_STORAGE_DB`)
- Nextcloud OIDC rotates refresh tokens on every use (one-time use)
- `TokenBrokerService` handles token lifecycle
- Per-user locking prevents race conditions during concurrent refresh
**Implementation:**
- `token_broker.py:269-362` - `get_background_token()` handles refresh with locking
- `token_broker.py:428-509` - `_refresh_access_token_with_scopes()` exchanges refresh token
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 4. OAuth Token Exchange (RFC 8693)
**Use Case:** Multi-user deployment where MCP tokens are separate from Nextcloud tokens. Provides stronger security boundaries.
Enabled by `ENABLE_TOKEN_EXCHANGE=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud OIDC
│ │ │
│── Bearer Token ────────────▶│ │
│ aud: "mcp-server" │ │
│ (MCP audience only) │ │
│ │── Validate MCP audience ──▶│
│ │ │
│ │── RFC 8693 Exchange ──────▶│
│ │ grant_type= │
│ │ urn:ietf:params:oauth: │
│ │ grant-type:token-exchange
│ │ subject_token=<mcp-token>│
│ │ requested_audience= │
│ │ "nextcloud" │
│ │◀── Delegated Token ────────│
│ │ aud: "nextcloud" │
│ │ │
│ │── HTTP + Delegated Token ─▶│
│ │ Authorization: Bearer │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Strict audience separation: MCP token has `aud: "mcp-server"` only
- Server exchanges for Nextcloud-audience token on each request
- Ephemeral delegated tokens (not cached by default)
- Strongest security boundary between MCP and Nextcloud access
**Token exchange details:**
- Uses RFC 8693 "urn:ietf:params:oauth:grant-type:token-exchange"
- Subject token: MCP access token
- Requested audience: Nextcloud resource URI
- Result: Short-lived token scoped for Nextcloud
**Implementation:**
- `token_broker.py:220-267` - `get_session_token()` performs on-demand exchange
- `token_exchange.py` - `exchange_token_for_delegation()` implements RFC 8693
- `context.py:88-94` - Routes to session client in exchange mode
#### Background Sync
Same as OAuth Single-Audience. Uses stored refresh tokens from Flow 2 provisioning.
```
MCP Server Nextcloud OIDC
│ │
[User provisions access] │ │
│── Flow 2 OAuth ───────────▶│
│ client_id="mcp-server" │
│ scope=offline_access ... │
│◀── Refresh Token ──────────│
│ (stored encrypted) │
│ │
[Background Job runs later] │ │
│── Refresh for background ─▶│
│ (same as single-audience)│
```
**Key difference from interactive:**
- Interactive: On-demand token exchange per request
- Background: Uses pre-provisioned refresh tokens (Flow 2)
#### Astrolabe → MCP Server
Same as Multi-User BasicAuth. See [Astrolabe → MCP Server](#astrolabe--mcp-server) above.
---
### 5. Smithery Stateless
**Use Case:** Multi-tenant SaaS deployment via Smithery platform. Fully stateless.
Enabled by `SMITHERY_DEPLOYMENT=true`.
#### MCP Client → MCP Server → Nextcloud
```
MCP Client MCP Server Nextcloud
│ │ │
│── SSE Connect ─────────────▶│ │
│ ?nextcloud_url=... │ │
│ &username=... │ │
│ &app_password=... │ │
│ │── SmitheryConfigMiddleware │
│ │ Extract URL params │
│ │ │
│── MCP Request ─────────────▶│ │
│ (no Authorization header) │ │
│ │── Create per-request ─────▶│
│ │ NextcloudClient │
│ │ │
│ │── HTTP + BasicAuth ───────▶│
│ │ (from session params) │
│ │◀── API Response ───────────│
│◀── Tool Result ─────────────│ │
```
**Key characteristics:**
- Configuration passed via URL query parameters (Smithery `configSchema`)
- No persistent state - client created fresh per request
- No OAuth infrastructure
- No background sync support (stateless)
- No admin UI available
**Required session parameters:**
- `nextcloud_url`: Nextcloud instance URL
- `username`: Nextcloud username
- `app_password`: Nextcloud app password
**Implementation:** `context.py:108-184` - `_get_client_from_session_config()` creates client from session params
#### Background Sync
Not supported. Smithery mode is fully stateless with no credential storage.
#### Astrolabe Integration
Not applicable. Smithery deployments don't integrate with Astrolabe.
---
## Configuration Quick Reference
### Single-User BasicAuth
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
```
### Multi-User BasicAuth
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Single-Audience (Default)
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No username/password triggers OAuth mode
# Optional: Static client credentials (instead of DCR)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### OAuth Token Exchange
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
# Optional: For background sync
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<32-byte-key>
TOKEN_STORAGE_DB=/data/tokens.db
```
### Smithery Stateless
```bash
SMITHERY_DEPLOYMENT=true
# All other config comes from session URL parameters
```
---
## Related Documentation
- [Authentication](authentication.md) - Configuration details and setup guides
- [OAuth Architecture](oauth-architecture.md) - Deep OAuth protocol details
- [ADR-004: Progressive Consent](ADR-004-mcp-application-oauth.md) - Dual OAuth flow architecture
- [ADR-005: Token Audience Validation](ADR-005-token-audience-validation.md) - Audience validation strategy
- [ADR-018: Nextcloud PHP App](ADR-018-nextcloud-php-app-for-settings-ui.md) - Astrolabe integration
- [ADR-020: Deployment Modes](ADR-020-deployment-modes-and-configuration-validation.md) - Mode detection and validation
-91
View File
@@ -140,97 +140,6 @@ Basic Authentication uses username and password credentials directly.
- [Configuration](configuration.md#basic-authentication-legacy) - BasicAuth environment variables
- [Running the Server](running.md#basicauth-mode-legacy) - BasicAuth examples
## Hybrid Authentication (Multi-User BasicAuth + OAuth)
When running in multi-user BasicAuth mode with `ENABLE_OFFLINE_ACCESS=true`, the server operates in **hybrid authentication mode**. This provides the simplicity of BasicAuth for normal operations with the security of OAuth for administrative functions.
### Authentication Domains
**MCP Operations** (Tools, Resources):
- **Auth Method**: BasicAuth (HTTP Basic username/password)
- **Characteristics**:
- Stateless - no token storage
- Simple configuration
- Direct credential validation against Nextcloud
- Credentials passed per-request in Authorization header
- **Used For**: MCP tool calls from Claude, MCP client operations
**Management APIs** (Webhooks, Admin UI):
- **Auth Method**: OAuth bearer tokens
- **Characteristics**:
- Per-user authorization via OAuth consent flow
- Refresh tokens stored for background operations
- Token validation via UnifiedTokenVerifier
- Explicit user consent required
- **Used For**: Astrolabe admin UI, webhook management, vector sync operations
### Configuration
```env
# Enable multi-user BasicAuth
ENABLE_MULTI_USER_BASIC_AUTH=true
# Enable hybrid mode (OAuth provisioning for management APIs)
ENABLE_OFFLINE_ACCESS=true
# Enable background sync (required for hybrid mode currently)
VECTOR_SYNC_ENABLED=true
# Encryption key for refresh token storage
TOKEN_ENCRYPTION_KEY=<base64-encoded-key>
# Nextcloud connection
NEXTCLOUD_HOST=https://cloud.example.com
# OAuth credentials (optional - uses DCR if not set)
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
### OAuth Provisioning Flow
1. Admin opens Astrolabe admin settings in Nextcloud
2. Clicks "Authorize" to enable webhook management
3. Redirected to `/oauth/authorize-nextcloud` on MCP server
4. MCP server redirects to Nextcloud OAuth consent page
5. Admin grants OAuth consent (scopes: `openid`, `profile`, `offline_access`)
6. Redirected back to `/oauth/callback` on MCP server
7. MCP server stores refresh token (encrypted)
8. Admin can now manage webhooks from Astrolabe UI
### Benefits
- **Simple MCP client setup**: Use BasicAuth (no OAuth complexity for end users)
- **Secure background operations**: Webhooks use per-user OAuth tokens (no shared credentials)
- **Explicit authorization**: Admins must explicitly grant OAuth consent for webhook operations
- **Per-user isolation**: Each admin's webhook operations use their own refresh token
### Trade-offs
- **Two auth systems**: More complex server configuration than pure BasicAuth or OAuth
- **OAuth setup required**: Admins must complete OAuth flow before managing webhooks
- **Token storage**: Requires database and encryption key for refresh tokens
### Comparison
| Feature | Pure BasicAuth | Hybrid Mode | Pure OAuth |
|---------|---------------|-------------|------------|
| MCP Operations | BasicAuth | BasicAuth | OAuth Bearer Token |
| Management API | N/A | OAuth Bearer Token | OAuth Bearer Token |
| Webhook Operations | N/A | OAuth Refresh Token | OAuth Refresh Token |
| MCP Client Setup | Simple | Simple | Complex (PKCE flow) |
| Admin UI Auth | N/A | OAuth Consent | OAuth Login |
| Token Storage | None | Refresh tokens only | All tokens |
| Deployment Complexity | Low | Medium | High |
### Astrolabe User Setup (Hybrid Mode)
For Astrolabe-specific user setup instructions in hybrid mode, see the [Astrolabe documentation](https://github.com/cbcoutinho/astrolabe/blob/master/docs/user-setup-hybrid-mode.md).
### See Also
- [OAuth Architecture](oauth-architecture.md) - Progressive Consent (Flow 2) details
- [Configuration](configuration.md#enable_offline_access) - Hybrid mode configuration
## Mode Detection
The server automatically detects the authentication mode:
-564
View File
@@ -1,564 +0,0 @@
# Configuration Migration Guide v2
**Version:** v0.58.0
**Status:** Active
**Related ADR:** [ADR-021: Configuration Consolidation and Simplification](ADR-021-configuration-consolidation.md)
## Overview
This guide helps you migrate from the old configuration variables to the new consolidated approach introduced in v0.58.0.
**Key Changes:**
- `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
- `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
- New: `MCP_DEPLOYMENT_MODE` for explicit mode selection
- Automatic dependency resolution: semantic search auto-enables background operations
**Backward Compatibility:**
- Old variable names still work in v0.58.0+
- Deprecation warnings logged when old names used
- Old names will be removed in v1.0.0
---
## Quick Reference: Variable Name Changes
| Old Name | New Name | Status |
|----------|----------|--------|
| `VECTOR_SYNC_ENABLED` | `ENABLE_SEMANTIC_SEARCH` | Deprecated |
| `ENABLE_OFFLINE_ACCESS` | `ENABLE_BACKGROUND_OPERATIONS` | Deprecated |
| N/A (auto-detected) | `MCP_DEPLOYMENT_MODE` | New (optional) |
**Tuning parameters unchanged:**
- `VECTOR_SYNC_SCAN_INTERVAL` - Keep as-is
- `VECTOR_SYNC_PROCESSOR_WORKERS` - Keep as-is
- `VECTOR_SYNC_QUEUE_MAX_SIZE` - Keep as-is
---
## Migration Scenarios
### Scenario 1: Single-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=single_user_basic
# Updated variable name
ENABLE_SEMANTIC_SEARCH=true # Previously VECTOR_SYNC_ENABLED
QDRANT_LOCATION=:memory:
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional `MCP_DEPLOYMENT_MODE` for clarity
- ✅ Background operations NOT auto-enabled (not needed in single-user mode)
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=single_user_basic`
3. Restart server
4. Verify deprecation warnings are gone
---
### Scenario 2: Multi-User OAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Both variables required - confusing!
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# One variable does it all!
ENABLE_SEMANTIC_SEARCH=true # Automatically enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
# Background operations are auto-enabled by ENABLE_SEMANTIC_SEARCH
```
**What Changed:**
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
-`ENABLE_SEMANTIC_SEARCH` automatically enables background operations in multi-user modes
- ✅ Renamed `VECTOR_SYNC_ENABLED` to `ENABLE_SEMANTIC_SEARCH`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
4. Restart server
5. Check logs for confirmation: "Automatically enabled background operations for semantic search"
---
### Scenario 3: Multi-User OAuth WITHOUT Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Enable background operations for future features
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Renamed for clarity
ENABLE_BACKGROUND_OPERATIONS=true # Previously ENABLE_OFFLINE_ACCESS
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**What Changed:**
- ✅ Renamed `ENABLE_OFFLINE_ACCESS` to `ENABLE_BACKGROUND_OPERATIONS`
- ✅ Added optional explicit mode declaration
**Migration Steps:**
1. Replace `ENABLE_OFFLINE_ACCESS=true` with `ENABLE_BACKGROUND_OPERATIONS=true`
2. Optionally add `MCP_DEPLOYMENT_MODE=oauth_single_audience`
3. Restart server
---
### Scenario 4: Multi-User BasicAuth with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Both required - redundant
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=multi_user_basic
# One variable handles both!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
NEXTCLOUD_OIDC_CLIENT_ID=mcp-server
NEXTCLOUD_OIDC_CLIENT_SECRET=secret
# Note: ENABLE_OFFLINE_ACCESS no longer needed!
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Removed need for explicit `ENABLE_OFFLINE_ACCESS`
- ✅ Clearer variable naming
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=multi_user_basic`
4. Restart server
---
### Scenario 5: Token Exchange Mode with Semantic Search
**Before (v0.57.x):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Both required
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**After (v0.58.0+ - Simplified):**
```bash
NEXTCLOUD_HOST=https://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# One variable!
ENABLE_SEMANTIC_SEARCH=true # Auto-enables background operations
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
TOKEN_EXCHANGE_CACHE_TTL=300
QDRANT_URL=http://qdrant:6333
OLLAMA_BASE_URL=http://ollama:11434
```
**What Changed:**
- ✅ Semantic search auto-enables background operations
- ✅ Explicit mode declaration available
**Migration Steps:**
1. Replace `VECTOR_SYNC_ENABLED=true` with `ENABLE_SEMANTIC_SEARCH=true`
2. Remove `ENABLE_OFFLINE_ACCESS=true` (auto-enabled)
3. Optionally add `MCP_DEPLOYMENT_MODE=oauth_token_exchange`
4. Restart server
---
## Understanding Automatic Dependency Resolution
### How It Works
In v0.58.0+, the server uses smart dependency resolution:
```python
# In multi-user modes (OAuth, Multi-User BasicAuth):
if ENABLE_SEMANTIC_SEARCH == true:
background_operations = automatically enabled
refresh_tokens = automatically requested
token_storage = required (TOKEN_ENCRYPTION_KEY, TOKEN_STORAGE_DB)
oauth_credentials = required (for app password retrieval)
```
**What this means:**
- ✅ Set `ENABLE_SEMANTIC_SEARCH=true`
- ✅ Provide required infrastructure (Qdrant, Ollama, encryption key)
- ✅ System automatically enables background operations
- ❌ No need to set `ENABLE_BACKGROUND_OPERATIONS` separately
### When Automatic Enablement Happens
| Deployment Mode | Semantic Search Enabled | Background Operations Auto-Enabled? |
|----------------|------------------------|-----------------------------------|
| Single-User BasicAuth | ✅ | ❌ No (not needed) |
| Multi-User BasicAuth | ✅ | ✅ Yes |
| OAuth Single-Audience | ✅ | ✅ Yes |
| OAuth Token Exchange | ✅ | ✅ Yes |
| Smithery Stateless | N/A (not supported) | N/A |
### When to Explicitly Set ENABLE_BACKGROUND_OPERATIONS
Only needed when you want background operations **without** semantic search:
```bash
# Example: OAuth mode with background operations but NO semantic search
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Explicitly enable background operations for future features
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Semantic search disabled
ENABLE_SEMANTIC_SEARCH=false
```
---
## Explicit Mode Selection
### Why Use MCP_DEPLOYMENT_MODE?
**Benefits:**
- ✅ Removes ambiguity about which mode is active
- ✅ Validation errors reference specific mode requirements
- ✅ Catches configuration mistakes early
- ✅ Self-documenting configuration
**Example:**
```bash
# Without explicit mode:
NEXTCLOUD_HOST=https://nextcloud.example.com
# Is this OAuth or Multi-User BasicAuth? Not immediately clear.
# With explicit mode:
MCP_DEPLOYMENT_MODE=oauth_single_audience
NEXTCLOUD_HOST=https://nextcloud.example.com
# Clear: This is OAuth mode
```
### Valid Mode Values
| Mode Value | Description |
|-----------|-------------|
| `single_user_basic` | Single-user with username/password |
| `multi_user_basic` | Multi-user with BasicAuth pass-through |
| `oauth_single_audience` | Multi-user OAuth (recommended) |
| `oauth_token_exchange` | Multi-user OAuth with token exchange |
| `smithery` | Smithery platform deployment |
### Mode Detection Priority
When `MCP_DEPLOYMENT_MODE` is set:
1. ✅ Explicit mode is used
2. ✅ Server validates configuration matches explicit mode
3. ❌ Auto-detection is skipped
When `MCP_DEPLOYMENT_MODE` is NOT set:
1. ✅ Auto-detection runs (existing behavior)
2. ✅ Priority: Smithery → Token Exchange → Multi-User BasicAuth → Single-User BasicAuth → OAuth Single-Audience
---
## Validation and Error Messages
### Old Validation (v0.57.x)
```
Error: [multi_user_basic] ENABLE_OFFLINE_ACCESS is required when VECTOR_SYNC_ENABLED is enabled
```
**Problem:** User must understand internal dependency relationship
### New Validation (v0.58.0+)
```
Error: [multi_user_basic] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Benefit:** Clear what's needed, no mention of internal ENABLE_BACKGROUND_OPERATIONS flag
---
## Troubleshooting Migration
### Issue: Deprecation Warning After Migration
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Solution:**
1. Check for `VECTOR_SYNC_ENABLED` in `.env` file
2. Replace with `ENABLE_SEMANTIC_SEARCH`
3. Search for any scripts/CI configs using old name
4. Restart server
### Issue: Both Old and New Names Set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Solution:**
1. Remove `VECTOR_SYNC_ENABLED` from `.env`
2. Keep `ENABLE_SEMANTIC_SEARCH`
3. Restart server
### Issue: Missing Required Dependencies
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Solution:**
When semantic search is enabled in multi-user modes, you need:
- `TOKEN_ENCRYPTION_KEY` - Generate with: `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"`
- `TOKEN_STORAGE_DB` - Path to SQLite database (e.g., `/app/data/tokens.db`)
- `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` - For app password retrieval
### Issue: Unexpected Mode Detected
**Symptom:**
Server activates `oauth_single_audience` mode when you expected `multi_user_basic`
**Solution:**
Add explicit mode declaration:
```bash
MCP_DEPLOYMENT_MODE=multi_user_basic
ENABLE_MULTI_USER_BASIC_AUTH=true
```
---
## Testing Your Migration
### Step 1: Verify Configuration
```bash
# Set new variable names in .env
cat .env | grep -E "(ENABLE_SEMANTIC_SEARCH|ENABLE_BACKGROUND_OPERATIONS|MCP_DEPLOYMENT_MODE)"
```
### Step 2: Check for Old Variable Names
```bash
# Should return nothing after migration
cat .env | grep -E "(VECTOR_SYNC_ENABLED|ENABLE_OFFLINE_ACCESS)"
```
### Step 3: Start Server and Check Logs
```bash
# Start server
docker-compose up mcp
# Look for:
# 1. No deprecation warnings
# 2. Correct mode detected
# 3. Auto-enablement messages (if using semantic search in multi-user mode)
```
**Expected Log Output (Multi-User OAuth + Semantic Search):**
```
INFO: Using explicit deployment mode: oauth_single_audience
INFO: Automatically enabled background operations for semantic search in multi-user mode.
INFO: Vector sync enabled. Starting background scanner...
```
### Step 4: Verify Functionality
Test that existing features still work:
- [ ] Semantic search returns results
- [ ] Background indexing runs
- [ ] OAuth flow completes successfully
- [ ] Refresh tokens are stored/retrieved
---
## Quick Start Templates
We provide mode-specific templates for new deployments:
| Template | Use Case |
|----------|----------|
| `env.sample.single-user` | Simplest setup |
| `env.sample.oauth-multi-user` | Recommended multi-user |
| `env.sample.oauth-advanced` | Token exchange mode |
**Usage:**
```bash
cp env.sample.oauth-multi-user .env
# Edit .env with your values
docker-compose up -d
```
---
## Timeline and Support
| Version | Status | Old Variable Support |
|---------|--------|---------------------|
| v0.57.x | Stable | Old names only |
| v0.58.0 | Current | Both old and new (with warnings) |
| v1.0.0 | Breaking | New names only |
**Recommendation:** Migrate before v1.0.0 (12+ months minimum)
---
## Getting Help
If you encounter issues during migration:
1. **Check the logs** - Look for deprecation warnings and error messages
2. **Review ADR-021** - See [docs/ADR-021-configuration-consolidation.md](ADR-021-configuration-consolidation.md)
3. **Use mode-specific templates** - See `env.sample.*` files
4. **File an issue** - Include your `.env` (redacted), logs, and mode
---
## Summary
**What You Need to Do:**
1. ✅ Rename `VECTOR_SYNC_ENABLED``ENABLE_SEMANTIC_SEARCH`
2. ✅ (Optional) Rename `ENABLE_OFFLINE_ACCESS``ENABLE_BACKGROUND_OPERATIONS`
3. ✅ (Recommended) Add `MCP_DEPLOYMENT_MODE` for clarity
4. ✅ Remove redundant settings (semantic search auto-enables background ops in multi-user modes)
5. ✅ Test your configuration
**What the Server Does Automatically:**
- ✅ Supports both old and new variable names
- ✅ Logs deprecation warnings for old names
- ✅ Auto-enables background operations when semantic search is enabled in multi-user modes
- ✅ Validates configuration and provides clear error messages
**Migration Timeline:**
- Now → v1.0.0: Both old and new names work
- v1.0.0+: Only new names supported
**Questions?** See [docs/configuration.md](configuration.md) or file an issue.
+15 -181
View File
@@ -2,82 +2,25 @@
The Nextcloud MCP server requires configuration to connect to your Nextcloud instance. Configuration is provided through environment variables, typically stored in a `.env` file.
> **Note:** Configuration was significantly simplified in v0.58.0. If you're upgrading from v0.57.x, see the [Configuration Migration Guide](configuration-migration-v2.md).
## Quick Start
We provide mode-specific configuration templates for quick setup:
Create a `.env` file based on `env.sample`:
```bash
# Choose a template based on your deployment mode:
cp env.sample.single-user .env # Simplest - one user, local dev
cp env.sample.oauth-multi-user .env # Recommended - multi-user OAuth
cp env.sample.oauth-advanced .env # Advanced - token exchange mode
# Or start from the full example:
cp env.sample .env
# Edit .env with your Nextcloud details
```
Then choose your deployment mode:
Then choose your authentication mode:
- [Single-User BasicAuth](#single-user-basicauth-mode) - Simplest for personal instances
- [Multi-User OAuth](#multi-user-oauth-modes) - Recommended for production
- [Deployment Mode Selection](#deployment-mode-selection) - Explicit mode declaration
- [OAuth2/OIDC Configuration](#oauth2oidc-configuration) (Recommended)
- [Basic Authentication Configuration](#basic-authentication-legacy)
---
## Deployment Mode Selection
## OAuth2/OIDC Configuration
**New in v0.58.0:** You can explicitly declare your deployment mode to remove ambiguity and catch configuration errors early.
```dotenv
# Optional but recommended
MCP_DEPLOYMENT_MODE=oauth_single_audience
```
**Valid values:**
- `single_user_basic` - Single-user with username/password
- `multi_user_basic` - Multi-user with BasicAuth pass-through
- `oauth_single_audience` - Multi-user OAuth (recommended)
- `oauth_token_exchange` - Multi-user OAuth with token exchange
- `smithery` - Smithery platform deployment
**Benefits:**
- ✅ Clear which mode is active
- ✅ Better validation error messages
- ✅ Self-documenting configuration
- ✅ Catches configuration mistakes early
**Auto-detection:** If `MCP_DEPLOYMENT_MODE` is not set, the server auto-detects the mode based on other settings (existing behavior).
See [Authentication Modes](authentication.md) for detailed comparison of deployment modes.
---
## Single-User BasicAuth Mode
BasicAuth with a single user is the simplest deployment mode. Use for personal instances, local development, and testing.
```dotenv
# Minimal single-user configuration
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Optional: Explicit mode declaration
MCP_DEPLOYMENT_MODE=single_user_basic
```
> [!WARNING]
> **Security Notice:** BasicAuth stores credentials in environment variables and is less secure than OAuth. Use OAuth for production multi-user deployments.
---
## Multi-User OAuth Modes
OAuth2/OIDC is the recommended authentication mode for production multi-user deployments.
OAuth2/OIDC is the recommended authentication mode for production deployments.
### Minimal Configuration (Auto-registration)
@@ -85,9 +28,6 @@ OAuth2/OIDC is the recommended authentication mode for production multi-user dep
# .env file for OAuth with auto-registration
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Leave these EMPTY for OAuth mode
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
@@ -101,9 +41,6 @@ This minimal configuration uses dynamic client registration to automatically reg
# .env file for OAuth with pre-configured client
NEXTCLOUD_HOST=https://your.nextcloud.instance.com
# Optional: Explicit mode declaration (recommended)
MCP_DEPLOYMENT_MODE=oauth_single_audience
# OAuth Client Credentials (optional - auto-registers if not provided)
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
@@ -171,104 +108,10 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
---
## SSL/TLS Configuration (Optional)
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
### Custom CA Bundle (Recommended)
Point the server at your CA certificate file:
```dotenv
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
```
With Docker, mount the certificate as a read-only volume:
```bash
docker run \
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
-e NEXTCLOUD_HOST=https://nextcloud.local \
--env-file .env \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
### Disable Verification (Development Only)
> [!WARNING]
> Disabling TLS verification is insecure. Only use this for local development or testing.
```dotenv
NEXTCLOUD_VERIFY_SSL=false
```
### Environment Variables Reference
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
### Scope
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
- OIDC discovery and token endpoints
- OAuth client registration (DCR)
- Health checks
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
---
## Semantic Search Configuration (Optional)
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
The MCP server includes semantic search capabilities powered by vector embeddings. This feature requires a vector database (Qdrant) and an embedding service.
### Quick Start
**Single-User Mode:**
```dotenv
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# Enable semantic search
ENABLE_SEMANTIC_SEARCH=true
# Vector database
QDRANT_LOCATION=:memory:
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
**Multi-User OAuth Mode:**
```dotenv
NEXTCLOUD_HOST=https://nextcloud.example.com
MCP_DEPLOYMENT_MODE=oauth_single_audience
# Enable semantic search
# In multi-user modes, this AUTOMATICALLY enables background operations!
ENABLE_SEMANTIC_SEARCH=true
# Required for background operations (auto-enabled by semantic search)
TOKEN_ENCRYPTION_KEY=your-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# Vector database
QDRANT_URL=http://qdrant:6333
# Embedding provider
OLLAMA_BASE_URL=http://ollama:11434
```
> **Note:** In multi-user modes (OAuth, Multi-User BasicAuth), enabling `ENABLE_SEMANTIC_SEARCH` automatically enables background operations and refresh token storage. You don't need to set `ENABLE_BACKGROUND_OPERATIONS` separately!
### Qdrant Vector Database Modes
The server supports three Qdrant deployment modes:
@@ -283,7 +126,7 @@ No configuration needed! If neither `QDRANT_URL` nor `QDRANT_LOCATION` is set, t
```dotenv
# No Qdrant configuration needed - defaults to :memory:
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -302,7 +145,7 @@ For single-instance deployments that need persistence without a separate Qdrant
```dotenv
# Local persistent storage
QDRANT_LOCATION=/app/data/qdrant # Or any writable path
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -323,7 +166,7 @@ For production deployments with a dedicated Qdrant service:
QDRANT_URL=http://qdrant:6333
QDRANT_API_KEY=your-secret-api-key # Optional
QDRANT_COLLECTION=nextcloud_content # Optional
ENABLE_SEMANTIC_SEARCH=true
VECTOR_SYNC_ENABLED=true
```
**Pros:**
@@ -440,15 +283,13 @@ Solutions:
- Data corruption in Qdrant
- Confusing error messages during indexing
### Background Indexing Configuration
### Vector Sync Configuration
Control background indexing behavior:
```dotenv
# Semantic search (ADR-007, ADR-021)
ENABLE_SEMANTIC_SEARCH=true # Enable background indexing
# Tuning parameters (advanced - only modify if needed)
# Vector sync settings (ADR-007)
VECTOR_SYNC_ENABLED=true # Enable background indexing
VECTOR_SYNC_SCAN_INTERVAL=300 # Scan interval in seconds (default: 5 minutes)
VECTOR_SYNC_PROCESSOR_WORKERS=3 # Concurrent indexing workers (default: 3)
VECTOR_SYNC_QUEUE_MAX_SIZE=10000 # Max queued documents (default: 10000)
@@ -458,8 +299,6 @@ DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words between chunks (default: 50)
```
> **Note:** The `VECTOR_SYNC_*` tuning parameters keep their names as they're implementation details. Only the user-facing feature flag was renamed to `ENABLE_SEMANTIC_SEARCH`.
### Embedding Service Configuration
The server uses an embedding service to generate vector representations. Two options are available:
@@ -530,11 +369,11 @@ DOCUMENT_CHUNK_OVERLAP=100
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `ENABLE_SEMANTIC_SEARCH` | ⚠️ Optional | `false` | Enable semantic search with background indexing (replaces `VECTOR_SYNC_ENABLED`) |
| `QDRANT_URL` | ⚠️ Optional | - | Qdrant service URL (network mode) - mutually exclusive with `QDRANT_LOCATION` |
| `QDRANT_LOCATION` | ⚠️ Optional | `:memory:` | Local Qdrant path (`:memory:` or `/path/to/data`) - mutually exclusive with `QDRANT_URL` |
| `QDRANT_API_KEY` | ⚠️ Optional | - | Qdrant API key (network mode only) |
| `QDRANT_COLLECTION` | ⚠️ Optional | Auto-generated | Qdrant collection name |
| `QDRANT_COLLECTION` | ⚠️ Optional | `nextcloud_content` | Qdrant collection name |
| `VECTOR_SYNC_ENABLED` | ⚠️ Optional | `false` | Enable background vector indexing |
| `VECTOR_SYNC_SCAN_INTERVAL` | ⚠️ Optional | `300` | Document scan interval (seconds) |
| `VECTOR_SYNC_PROCESSOR_WORKERS` | ⚠️ Optional | `3` | Concurrent indexing workers |
| `VECTOR_SYNC_QUEUE_MAX_SIZE` | ⚠️ Optional | `10000` | Max queued documents |
@@ -544,9 +383,6 @@ DOCUMENT_CHUNK_OVERLAP=100
| `DOCUMENT_CHUNK_SIZE` | ⚠️ Optional | `512` | Words per chunk for document embedding |
| `DOCUMENT_CHUNK_OVERLAP` | ⚠️ Optional | `50` | Overlapping words between chunks (must be < chunk size) |
**Deprecated variables (still functional):**
- `VECTOR_SYNC_ENABLED` - Use `ENABLE_SEMANTIC_SEARCH` instead (will be removed in v1.0.0)
### Docker Compose Example
Enable network mode Qdrant with docker-compose:
@@ -556,7 +392,7 @@ services:
mcp:
environment:
- QDRANT_URL=http://qdrant:6333
- ENABLE_SEMANTIC_SEARCH=true
- VECTOR_SYNC_ENABLED=true
qdrant:
image: qdrant/qdrant:latest
@@ -709,7 +545,6 @@ uv run nextcloud-mcp-server --no-oauth \
## See Also
- [Configuration Migration Guide v2](configuration-migration-v2.md) - **New in v0.58.0:** Migrate from old variable names
- [OAuth Quick Start](quickstart-oauth.md) - 5-minute OAuth setup for development
- [OAuth Setup Guide](oauth-setup.md) - Detailed OAuth configuration for production
- [OAuth Architecture](oauth-architecture.md) - How OAuth works in the MCP server
@@ -718,4 +553,3 @@ uv run nextcloud-mcp-server --no-oauth \
- [Running the Server](running.md) - Starting the server with different configurations
- [Troubleshooting](troubleshooting.md) - Common configuration issues
- [OAuth Troubleshooting](oauth-troubleshooting.md) - OAuth-specific troubleshooting
- [ADR-021](ADR-021-configuration-consolidation.md) - Configuration consolidation architecture decision
-140
View File
@@ -4,146 +4,6 @@ This guide covers common issues and solutions for the Nextcloud MCP server.
> **OAuth-specific issues?** See the dedicated [OAuth Troubleshooting Guide](oauth-troubleshooting.md) for OAuth authentication problems, OIDC discovery issues, token validation failures, and more.
> **Upgrading from v0.57.x?** See the [Configuration Migration Guide](configuration-migration-v2.md) for help with new variable names.
## Configuration Issues (v0.58.0+)
### Issue: Deprecation warning for VECTOR_SYNC_ENABLED
**Symptom:**
```
WARNING: VECTOR_SYNC_ENABLED is deprecated. Please use ENABLE_SEMANTIC_SEARCH instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
```bash
# In your .env file, replace:
VECTOR_SYNC_ENABLED=true
# With:
ENABLE_SEMANTIC_SEARCH=true
```
See [Configuration Migration Guide](configuration-migration-v2.md) for complete migration instructions.
---
### Issue: Deprecation warning for ENABLE_OFFLINE_ACCESS
**Symptom:**
```
WARNING: ENABLE_OFFLINE_ACCESS is deprecated. Please use ENABLE_BACKGROUND_OPERATIONS instead.
```
**Cause:** You're using the old variable name from v0.57.x.
**Solution:**
**If you have semantic search enabled:**
```bash
# In multi-user modes, you can remove ENABLE_OFFLINE_ACCESS entirely!
# ENABLE_SEMANTIC_SEARCH automatically enables background operations
# Before (v0.57.x):
ENABLE_OFFLINE_ACCESS=true
VECTOR_SYNC_ENABLED=true
# After (v0.58.0+):
ENABLE_SEMANTIC_SEARCH=true # This is all you need!
```
**If you only want background operations (no semantic search):**
```bash
# Replace:
ENABLE_OFFLINE_ACCESS=true
# With:
ENABLE_BACKGROUND_OPERATIONS=true
```
---
### Issue: "Invalid MCP_DEPLOYMENT_MODE"
**Symptom:**
```
ValueError: Invalid MCP_DEPLOYMENT_MODE: 'oauth'. Valid values: single_user_basic, multi_user_basic, oauth_single_audience, oauth_token_exchange, smithery
```
**Cause:** Invalid value for `MCP_DEPLOYMENT_MODE`.
**Solution:**
Use one of the valid mode values:
```bash
# Correct values:
MCP_DEPLOYMENT_MODE=single_user_basic # Single-user with username/password
MCP_DEPLOYMENT_MODE=multi_user_basic # Multi-user BasicAuth
MCP_DEPLOYMENT_MODE=oauth_single_audience # OAuth (recommended)
MCP_DEPLOYMENT_MODE=oauth_token_exchange # OAuth with token exchange
MCP_DEPLOYMENT_MODE=smithery # Smithery deployment
```
Or remove `MCP_DEPLOYMENT_MODE` to use automatic detection.
---
### Issue: Missing TOKEN_ENCRYPTION_KEY when semantic search enabled
**Symptom:**
```
Error: [oauth_single_audience] TOKEN_ENCRYPTION_KEY is required when ENABLE_SEMANTIC_SEARCH is enabled
```
**Cause:** In multi-user modes, semantic search automatically enables background operations, which require encrypted token storage.
**Solution:**
Generate an encryption key and add required token storage configuration:
```bash
# Generate encryption key
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Add to .env:
TOKEN_ENCRYPTION_KEY=<generated-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id # Required for app password retrieval
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
```
**Why this happens:**
- v0.58.0+ automatically enables background operations when `ENABLE_SEMANTIC_SEARCH=true` in multi-user modes
- Background operations need encrypted refresh token storage
- This simplifies configuration but requires the encryption infrastructure
See [Configuration Guide - Semantic Search](configuration.md#semantic-search-configuration-optional) for details.
---
### Issue: Both old and new variable names set
**Symptom:**
```
WARNING: Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. Using ENABLE_SEMANTIC_SEARCH.
```
**Cause:** You have both the old and new variable names in your configuration.
**Solution:**
Remove the old variable name:
```bash
# Remove this line:
VECTOR_SYNC_ENABLED=true
# Keep this line:
ENABLE_SEMANTIC_SEARCH=true
```
The server will use the new name and ignore the old one, but it's cleaner to remove the old variable entirely.
---
## OAuth Issues (Quick Reference)
### Issue: "OAuth mode requires NEXTCLOUD_HOST environment variable"
-339
View File
@@ -1,339 +0,0 @@
# Webhook Management Guide
This guide explains how to enable and disable webhooks for vector sync in each MCP server deployment mode. Webhooks enable near-real-time synchronization of content changes to the vector database, complementing the default polling-based sync.
**Related ADRs:**
- ADR-010: Webhook-Based Vector Sync
- ADR-020: Deployment Modes and Configuration Validation
## Prerequisites
Before enabling webhooks, ensure:
1. **Nextcloud 30+** with `webhook_listeners` app enabled
2. **[Astrolabe app](https://github.com/cbcoutinho/astrolabe)** installed in Nextcloud (provides settings UI and credentials API)
3. **MCP server** accessible from Nextcloud via HTTP(S)
4. **Vector sync enabled** on the MCP server
## Webhook Architecture Overview
The webhook system has two components:
1. **Webhook Registration** - Configuring Nextcloud to send change notifications to the MCP server
2. **Background Sync Credentials** - Allowing the MCP server to access Nextcloud APIs on behalf of users
Both must be configured for webhooks to function properly.
## Deployment Mode Specifics
### 1. Single-User BasicAuth
**Configuration:**
```bash
NEXTCLOUD_HOST=http://localhost:8080
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
1. Register webhooks using occ commands (requires Nextcloud admin):
```bash
# Enable webhook_listeners app
php occ app:enable webhook_listeners
# Register webhooks for vector sync
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8000/webhooks/nextcloud" \
--method POST
# Repeat for other events (see Event Types below)
```
2. Optionally reduce polling frequency:
```bash
VECTOR_SYNC_SCAN_INTERVAL=86400 # 24 hours
```
**Disable Webhooks:**
```bash
# List registered webhooks
php occ webhook_listeners:list
# Remove specific webhook by ID
php occ webhook_listeners:remove <webhook-id>
```
**Notes:**
- Simplest mode - admin credentials used for all operations
- No per-user provisioning required
- Background sync runs as the configured admin user
---
### 2. Multi-User BasicAuth Pass-Through
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_MULTI_USER_BASIC_AUTH=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
# OAuth client for Astrolabe API access
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
```
**Credential Architecture:**
This mode uses **two separate credential mechanisms**:
1. **OAuth Session** (for management API access, including webhooks):
- Obtained via browser OAuth flow (`/oauth/login`)
- Stores refresh token in MCP server's `tokens.db`
- Used for webhook registration/management APIs
2. **App Password** (for background sync):
- Generated in Nextcloud Security settings
- Stored encrypted in Nextcloud's `oc_preferences` via Astrolabe
- Used by background scanners to access Nextcloud APIs
**Enable Webhooks:**
#### Step 1: Complete OAuth Login (for Management API)
Users must authorize the MCP server to access their Nextcloud:
1. Navigate to **Nextcloud Settings → Astrolabe** (Personal settings)
2. Click **"Authorize via OAuth"** under "Option 1"
3. Complete OAuth consent flow
4. Verify the page shows "Background Sync Access: Active"
#### Step 2: Configure App Password (for Background Sync)
Since OAuth refresh tokens have short expiry, users should also configure an app password:
1. Navigate to **Nextcloud Settings → Security**
2. Generate a new app password (name it "Astrolabe" or "MCP Server")
3. Return to **Nextcloud Settings → Astrolabe**
4. Under "Option 2: App Password", paste the app password
5. Click **Save**
#### Step 3: Register Webhooks (Admin)
Same as Single-User BasicAuth:
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8003/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Revoke Access"** (for OAuth tokens) or **"Revoke Access"** (for app password)
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
**Troubleshooting:**
If OAuth login fails with "Access forbidden - Your client is not authorized":
1. Check if OAuth client is registered:
```sql
SELECT id, name, client_identifier FROM oc_oidc_clients
WHERE dcr = 1 ORDER BY id DESC LIMIT 5;
```
2. Restart MCP server to trigger DCR re-registration
3. Verify `NEXTCLOUD_OIDC_CLIENT_ID` and `NEXTCLOUD_OIDC_CLIENT_SECRET` are set
If background sync fails with "User no longer provisioned":
1. Verify app password is stored:
```sql
SELECT userid, configkey FROM oc_preferences
WHERE appid = 'astrolabe' AND userid = 'username';
```
2. Ensure user completed **both** OAuth login AND app password setup
---
### 3. OAuth Single-Audience (Default OAuth Mode)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
# No NEXTCLOUD_USERNAME/PASSWORD
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable Webhooks:**
#### Step 1: User Provisioning
Users authorize via OAuth with `offline_access` scope:
1. MCP client initiates OAuth flow
2. User consents to requested scopes including `offline_access`
3. MCP server stores refresh token for background operations
Alternatively, via Astrolabe UI:
1. Navigate to **Nextcloud Settings → Astrolabe**
2. Click **"Authorize via OAuth"**
3. Complete consent flow
#### Step 2: Register Webhooks (Admin)
```bash
php occ webhook_listeners:add \
--event "OCP\Files\Events\Node\NodeCreatedEvent" \
--uri "http://mcp-server:8001/webhooks/nextcloud" \
--method POST
```
**Disable Webhooks:**
*Per-User:*
- Via Astrolabe UI: Click "Disable Indexing" or "Disconnect"
- Via MCP tool: Use `revoke_nextcloud_access` if available
*System-Wide:*
```bash
php occ webhook_listeners:remove <webhook-id>
```
---
### 4. OAuth Token Exchange (RFC 8693)
**Configuration:**
```bash
NEXTCLOUD_HOST=http://nextcloud.example.com
ENABLE_TOKEN_EXCHANGE=true
ENABLE_BACKGROUND_OPERATIONS=true
TOKEN_ENCRYPTION_KEY=<key>
TOKEN_STORAGE_DB=/app/data/tokens.db
VECTOR_SYNC_ENABLED=true
```
**Enable/Disable Webhooks:**
Same process as OAuth Single-Audience. The token exchange happens transparently when the MCP server accesses Nextcloud APIs.
---
### 5. Smithery Stateless
**Configuration:**
- Configuration from session URL params
- `VECTOR_SYNC_ENABLED=false` (required)
**Webhooks:**
**Not supported.** This mode is stateless with no persistent storage or background operations.
---
## Webhook Event Types
Register these webhook events for full vector sync coverage:
### File/Note Events
```bash
# Use BeforeNodeDeletedEvent for deletions (includes node.id)
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\NodeWrittenEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Files\Events\Node\BeforeNodeDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Calendar Events
```bash
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectCreatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCP\Calendar\Events\CalendarObjectDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
### Tables Events
```bash
php occ webhook_listeners:add --event "OCA\Tables\Event\RowAddedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowUpdatedEvent" --uri "$MCP_URL/webhooks/nextcloud"
php occ webhook_listeners:add --event "OCA\Tables\Event\RowDeletedEvent" --uri "$MCP_URL/webhooks/nextcloud"
```
## Security Considerations
### Webhook Authentication
Configure `WEBHOOK_SECRET` to require authentication for incoming webhooks:
```bash
# MCP Server
WEBHOOK_SECRET=<generate-random-secret>
# Nextcloud webhook registration
php occ webhook_listeners:add \
--event "..." \
--uri "$MCP_URL/webhooks/nextcloud" \
--header "Authorization: Bearer <secret>"
```
### Token Storage
- Refresh tokens and app passwords are encrypted using `TOKEN_ENCRYPTION_KEY`
- Store the key securely (environment variable, secrets manager)
- Different users have isolated credential storage
## Monitoring
### MCP Server Logs
```bash
# Docker
docker compose logs mcp-multi-user-basic | grep -i webhook
# Key log messages
# - "Queued document from webhook: ..." - Success
# - "Webhook authentication failed" - Auth error
# - "User X no longer provisioned" - Missing credentials
```
### Nextcloud Logs
```bash
docker compose exec app cat /var/www/html/data/nextcloud.log | \
jq 'select(.message | contains("webhook"))' | tail
```
### Database Checks
```sql
-- Check registered webhooks
SELECT * FROM oc_webhook_listeners;
-- Check OAuth clients
SELECT id, name, token_type FROM oc_oidc_clients WHERE dcr = 1;
-- Check user credentials stored by Astrolabe app
SELECT userid, configkey FROM oc_preferences WHERE appid = 'astrolabe';
```
## Common Issues
### "Access forbidden - Your client is not authorized to connect"
**Cause:** OAuth client registration expired or not present in Nextcloud
**Fix:** Restart MCP server to trigger DCR re-registration
### "User X no longer provisioned, stopping scanner"
**Cause:** Background sync credentials missing or expired
**Fix:** User must complete credential provisioning (see mode-specific steps)
### "Failed to fetch" in browser console during OAuth
**Cause:** Network issue between browser and MCP server callback endpoint
**Fix:** Verify MCP server is accessible at the configured `NEXTCLOUD_MCP_SERVER_URL`
### Webhooks not firing
**Causes:**
1. `webhook_listeners` app not enabled
2. Webhook not registered for the event type
3. Background job workers not running
**Fix:**
```bash
php occ app:enable webhook_listeners
php occ background:cron # or configure systemd cron
```
+192 -238
View File
@@ -1,249 +1,203 @@
# ============================================
# DEPLOYMENT MODE SELECTION
# ============================================
# Optional: Explicitly declare deployment mode (ADR-021)
# If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
#
# Recommendation: Set this for clarity and to catch configuration errors early
#MCP_DEPLOYMENT_MODE=oauth_single_audience
# ============================================
# COMMON SETTINGS (Required for all modes)
# ============================================
# Your Nextcloud instance URL (without trailing slash)
# Nextcloud Instance
NEXTCLOUD_HOST=
# ============================================
# SINGLE-USER BASICAUTH MODE
# ============================================
# Simplest deployment - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Required:
# ===== AUTHENTICATION MODE =====
# Choose ONE of the following:
# Option 1: OAuth2/OIDC (RECOMMENDED - More Secure)
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#TOKEN_ENCRYPTION_KEY=
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
#TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== ADR-004 PROGRESSIVE CONSENT CONFIGURATION =====
# Enable Progressive Consent mode (dual OAuth flows)
# When enabled: Flow 1 for client auth, Flow 2 for Nextcloud resource access
# When disabled: Uses existing hybrid flow (backward compatible)
# MCP Server OAuth Client Configuration
# The MCP server's own OAuth client credentials for Flow 2
# If not set, will use dynamic client registration
#MCP_SERVER_CLIENT_ID=
#MCP_SERVER_CLIENT_SECRET=
# Allowed MCP Client IDs (comma-separated list)
# Client IDs that are allowed to authenticate in Flow 1
# Examples: claude-desktop,continue-dev,zed-editor
#ALLOWED_MCP_CLIENTS=claude-desktop,continue-dev,zed-editor
# Token cache configuration for Token Broker Service
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_CACHE_TTL=300
# Early refresh threshold in seconds (default: 30)
#TOKEN_CACHE_EARLY_REFRESH=30
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
# - Use only for backward compatibility or if OAuth unavailable
# - If these are set, OAuth mode is disabled
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# MULTI-USER BASICAUTH MODE
# ============================================
# Users provide credentials in request headers (pass-through)
# Use for: Multi-user without OAuth, simple shared deployments
#
# Required:
#ENABLE_MULTI_USER_BASIC_AUTH=true
#
# Optional - Background Operations (for semantic search, future features):
# Enable background token storage using app passwords (via Astrolabe)
# Required for semantic search in multi-user mode
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH SINGLE-AUDIENCE MODE (Recommended)
# ============================================
# Multi-user OAuth with single-audience tokens
# Use for: Multi-user production deployments, enhanced security
# Tokens work for both MCP server and Nextcloud APIs (pass-through)
#
# Required: None (uses Dynamic Client Registration if credentials not provided)
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Background Operations (for semantic search, future features):
# Enable refresh token storage for offline access
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
# Auto-detected from NEXTCLOUD_HOST if not set
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# Optional - Custom Scopes:
# Default: openid profile email offline_access notes:* calendar:* contacts:* tables:* webdav:* deck:* cookbook:*
#NEXTCLOUD_OIDC_SCOPES=openid profile email notes:* calendar:*
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# OAUTH TOKEN EXCHANGE MODE (Advanced)
# ============================================
# Multi-user OAuth with RFC 8693 token exchange
# Use for: Advanced deployments requiring separate MCP and Nextcloud tokens
# MCP tokens are separate from Nextcloud tokens
#
# Required:
#ENABLE_TOKEN_EXCHANGE=true
#
# Optional - Pre-registered OAuth Client:
# If you pre-register the client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=
#NEXTCLOUD_OIDC_CLIENT_SECRET=
#
# Optional - Token Exchange Configuration:
# Cache TTL in seconds (default: 300 = 5 minutes)
#TOKEN_EXCHANGE_CACHE_TTL=300
#
# Optional - Background Operations:
# Note: ENABLE_SEMANTIC_SEARCH automatically enables this in multi-user modes
#ENABLE_BACKGROUND_OPERATIONS=true
#TOKEN_ENCRYPTION_KEY=
#TOKEN_STORAGE_DB=/app/data/tokens.db
#
# Optional - Custom OIDC Discovery:
#NEXTCLOUD_OIDC_DISCOVERY_URL=
#
# MCP Server URL (for OAuth redirects):
#NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
#
# Optional features (semantic search, document processing):
# See "Optional Features" section below
# ============================================
# SMITHERY STATELESS MODE
# ============================================
# Stateless multi-tenant deployment for Smithery platform
# Configuration comes from session URL parameters
# No persistent storage, no OAuth, no vector sync
#
# Required: None (all config from session URL)
# This mode is activated automatically when deployed to Smithery
# ============================================
# OPTIONAL FEATURES (All Deployment Modes)
# ============================================
# ===== SEMANTIC SEARCH =====
# AI-powered semantic search across Nextcloud content
# Requires: Qdrant vector database + embedding provider (Ollama, Bedrock, or Simple fallback)
#
# Enable semantic search:
#ENABLE_SEMANTIC_SEARCH=true
#
# Note for Multi-User Modes:
# ENABLE_SEMANTIC_SEARCH automatically enables background operations when needed
# No need to set ENABLE_BACKGROUND_OPERATIONS separately
# The server will automatically request refresh tokens and store them encrypted
#
# Vector Database - Choose ONE mode:
# 1. In-memory (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network: Set QDRANT_URL=http://qdrant:6333
#
#QDRANT_URL=http://qdrant:6333
#QDRANT_LOCATION=:memory:
#QDRANT_API_KEY=
#QDRANT_COLLECTION=nextcloud_content
#
# Embedding Provider - Choose ONE:
# 1. Ollama (recommended for local deployment):
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
#OLLAMA_VERIFY_SSL=true
#
# 2. Amazon Bedrock (for AWS deployments):
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Optional: AWS credentials (uses credential chain if not set)
#AWS_ACCESS_KEY_ID=
#AWS_SECRET_ACCESS_KEY=
#
# 3. Simple (automatic fallback, no configuration needed)
# Uses basic in-memory embeddings if no provider configured
#
# Document Chunking:
# Configure how documents are split before embedding
#DOCUMENT_CHUNK_SIZE=512
#DOCUMENT_CHUNK_OVERLAP=50
# ===== SEMANTIC SEARCH TUNING =====
# Advanced parameters for vector sync background operations
# Only modify if you understand the implications
#
# Document scan interval in seconds (default: 300 = 5 minutes)
#VECTOR_SYNC_SCAN_INTERVAL=300
#
# Concurrent indexing workers (default: 3)
#VECTOR_SYNC_PROCESSOR_WORKERS=3
#
# Max queued documents (default: 10000)
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
# ===== DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX, etc. for semantic search
# Disabled by default
#
#ENABLE_DOCUMENT_PROCESSING=false
#DOCUMENT_PROCESSOR=unstructured
#
# Unstructured.io Processor (recommended):
#ENABLE_UNSTRUCTURED=false
#UNSTRUCTURED_API_URL=http://unstructured:8000
#UNSTRUCTURED_TIMEOUT=120
#UNSTRUCTURED_STRATEGY=auto
#UNSTRUCTURED_LANGUAGES=eng,deu
#PROGRESS_INTERVAL=10
#
# Tesseract OCR (lightweight, images only):
#ENABLE_TESSERACT=false
#TESSERACT_CMD=/usr/bin/tesseract
#TESSERACT_LANG=eng
#
# Custom Processor (your own API):
#ENABLE_CUSTOM_PROCESSOR=false
#CUSTOM_PROCESSOR_NAME=my_ocr
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
#CUSTOM_PROCESSOR_API_KEY=
#CUSTOM_PROCESSOR_TIMEOUT=60
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ===== SSL/TLS =====
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
#
# Disable TLS certificate verification (insecure, development only):
#NEXTCLOUD_VERIFY_SSL=false
#
# Use a custom CA bundle (path to PEM file):
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
#
# Docker example: mount the CA bundle as a volume
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
# ===== SECURITY & ADVANCED =====
# Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set
# Set explicitly for non-standard setups
#COOKIE_SECURE=true
# ============================================
# DEPRECATED VARIABLES (Backward Compatibility)
# Document Processing Configuration
# ============================================
# These variables still work but will be removed in v1.0.0
# Please migrate to new names:
#
# Old Name → New Name
# VECTOR_SYNC_ENABLED → ENABLE_SEMANTIC_SEARCH
# ENABLE_OFFLINE_ACCESS → ENABLE_BACKGROUND_OPERATIONS
#
# Migration is optional - both old and new names work
# Deprecation warnings will be logged when old names are used
# Enable document processing (PDF, DOCX, images, etc.)
# Set to false to disable all document processing
ENABLE_DOCUMENT_PROCESSING=false
# Default processor to use when multiple are available
# Options: unstructured, tesseract, custom
DOCUMENT_PROCESSOR=unstructured
# ============================================
# Unstructured.io Processor
# ============================================
# Enable Unstructured processor (requires unstructured service in docker-compose)
# This is a cloud-based/API processor supporting many document types
ENABLE_UNSTRUCTURED=false
# Unstructured API endpoint
UNSTRUCTURED_API_URL=http://unstructured:8000
# Request timeout in seconds (default: 120)
# OCR operations can take 30-120 seconds for large documents
UNSTRUCTURED_TIMEOUT=120
# Parsing strategy: auto, fast, hi_res
# - auto: Automatically choose based on document type
# - fast: Fast parsing without OCR
# - hi_res: High-resolution with OCR (slowest, most accurate)
UNSTRUCTURED_STRATEGY=auto
# OCR languages (comma-separated ISO 639-3 codes)
# Common: eng=English, deu=German, fra=French, spa=Spanish
UNSTRUCTURED_LANGUAGES=eng,deu
# Progress reporting interval in seconds (default: 10)
# During long-running OCR operations, progress notifications are sent to the MCP client
# at this interval to prevent timeouts and provide status updates
PROGRESS_INTERVAL=10
# ============================================
# Tesseract Processor (Local OCR)
# ============================================
# Enable Tesseract processor (requires tesseract binary installed)
# This is a local, lightweight OCR solution for images only
ENABLE_TESSERACT=false
# Path to tesseract executable (optional, auto-detected if in PATH)
#TESSERACT_CMD=/usr/bin/tesseract
# OCR language (e.g., eng, deu, eng+deu for multiple)
TESSERACT_LANG=eng
# ============================================
# Custom Processor (Your own API)
# ============================================
# Enable custom document processor via HTTP API
ENABLE_CUSTOM_PROCESSOR=false
# Unique name for your processor
#CUSTOM_PROCESSOR_NAME=my_ocr
# Your custom processor API endpoint
#CUSTOM_PROCESSOR_URL=http://localhost:9000/process
# Optional API key for authentication
#CUSTOM_PROCESSOR_API_KEY=your-api-key-here
# Request timeout in seconds
#CUSTOM_PROCESSOR_TIMEOUT=60
# Comma-separated MIME types your processor supports
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
# ============================================
# Semantic Search & Vector Sync Configuration
# ============================================
# EXPERIMENTAL: Semantic search for Notes app (multi-app support planned)
# Requires: Qdrant vector database + Ollama embedding service
# Disabled by default
# Enable background vector indexing
VECTOR_SYNC_ENABLED=false
# Document scan interval in seconds (default: 300 = 5 minutes)
# How often to check for new/updated documents
#VECTOR_SYNC_SCAN_INTERVAL=300
# Concurrent indexing workers (default: 3)
# Number of parallel workers for embedding generation
#VECTOR_SYNC_PROCESSOR_WORKERS=3
# Max queued documents (default: 10000)
# Maximum documents waiting to be processed
#VECTOR_SYNC_QUEUE_MAX_SIZE=10000
# ============================================
# Qdrant Vector Database Configuration
# ============================================
# Choose ONE of three modes:
# 1. In-memory mode (default): Set neither QDRANT_URL nor QDRANT_LOCATION
# 2. Persistent local: Set QDRANT_LOCATION=/path/to/data
# 3. Network mode: Set QDRANT_URL=http://qdrant:6333
# Network mode: URL to Qdrant service
#QDRANT_URL=http://qdrant:6333
# Local mode: Path to store vectors (use :memory: for in-memory)
#QDRANT_LOCATION=:memory:
# API key for network mode (optional)
#QDRANT_API_KEY=
# Collection name (optional - auto-generated if not set)
# Auto-generation format: {deployment-id}-{model-name}
# Allows safe model switching and multi-server deployments
#QDRANT_COLLECTION=nextcloud_content
# ============================================
# Ollama Embedding Service Configuration
# ============================================
# Ollama endpoint for embeddings (if not set, uses SimpleEmbeddingProvider fallback)
#OLLAMA_BASE_URL=http://ollama:11434
# Embedding model to use (default: nomic-embed-text, 768 dimensions)
# Changing this creates a new collection (requires re-embedding all documents)
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Verify SSL certificates (default: true)
#OLLAMA_VERIFY_SSL=true
# ============================================
# Document Chunking Configuration
# ============================================
# Configure how documents are split before embedding
# Words per chunk (default: 512)
# Smaller chunks (256-384): More precise, less context, more storage
# Larger chunks (768-1024): More context, less precise, less storage
#DOCUMENT_CHUNK_SIZE=512
# Overlapping words between chunks (default: 50)
# Recommended: 10-20% of chunk size
# Preserves context across chunk boundaries
#DOCUMENT_CHUNK_OVERLAP=50
-80
View File
@@ -1,80 +0,0 @@
# ============================================
# OAUTH TOKEN EXCHANGE QUICK START (Advanced)
# ============================================
# Advanced OAuth deployment with RFC 8693 token exchange
# Use for: Deployments requiring separate MCP and Nextcloud tokens
# Features: Dual-audience tokens, enhanced security boundaries
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# Enable token exchange mode
ENABLE_TOKEN_EXCHANGE=true
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_token_exchange
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: TOKEN EXCHANGE TUNING =====
# Cache TTL for exchanged tokens (default: 300 seconds = 5 minutes)
TOKEN_EXCHANGE_CACHE_TTL=300
# ===== OPTIONAL: SEMANTIC SEARCH =====
# AI-powered semantic search with automatic background operation setup
#
# Note: ENABLE_SEMANTIC_SEARCH automatically enables background operations
# in token exchange mode, just like in OAuth single-audience mode
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# Embedding Provider (required for semantic search)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== TOKEN EXCHANGE MODE EXPLANATION =====
# In this mode:
# 1. MCP clients authenticate with tokens scoped to "mcp-server" audience
# 2. Server exchanges MCP tokens for Nextcloud tokens on each request
# 3. Provides clear separation between MCP session and Nextcloud access
# 4. Enables fine-grained token lifecycle management
#
# When to use:
# - Strict security requirements (separate token contexts)
# - Complex multi-service architectures
# - Need independent token expiration policies
#
# When NOT to use:
# - Simple deployments (use oauth_single_audience instead)
# - High-performance requirements (token exchange adds latency)
# For more configuration options, see env.sample
-77
View File
@@ -1,77 +0,0 @@
# ============================================
# OAUTH MULTI-USER QUICK START (Recommended)
# ============================================
# Multi-user deployment with OAuth authentication
# Use for: Multi-user production deployments, enhanced security
# Features: Single-audience tokens, automatic client registration (DCR)
#
# Copy this file to .env and configure
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=https://nextcloud.example.com
# ===== REQUIRED: LEAVE USERNAME/PASSWORD EMPTY =====
# OAuth mode activates when these are NOT set
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended for clarity
MCP_DEPLOYMENT_MODE=oauth_single_audience
# ===== OPTIONAL: PRE-REGISTERED OAUTH CLIENT =====
# If you pre-register the OAuth client instead of using DCR:
#NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
#NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# MCP Server URL (for OAuth redirects)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# ===== OPTIONAL: SEMANTIC SEARCH (Recommended) =====
# AI-powered semantic search with automatic background operation setup
#
# When you enable semantic search in multi-user mode:
# 1. ENABLE_SEMANTIC_SEARCH automatically enables background operations
# 2. Server requests refresh tokens for offline indexing
# 3. Tokens are stored encrypted in TOKEN_STORAGE_DB
# 4. No need to set ENABLE_BACKGROUND_OPERATIONS separately!
#
ENABLE_SEMANTIC_SEARCH=true
# Vector Database (required for semantic search)
QDRANT_URL=http://qdrant:6333
# OR for in-memory mode:
#QDRANT_LOCATION=:memory:
# Embedding Provider (required for semantic search)
# Option 1: Ollama (recommended for local deployment)
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Option 2: Amazon Bedrock (for AWS deployments)
#AWS_REGION=us-east-1
#BEDROCK_EMBEDDING_MODEL=amazon.titan-embed-text-v2:0
# Token Storage (required for background operations - auto-enabled by semantic search)
# Generate encryption key: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOKEN_ENCRYPTION_KEY=your-encryption-key-here
TOKEN_STORAGE_DB=/app/data/tokens.db
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# ===== SUMMARY OF AUTO-ENABLEMENT =====
# With ENABLE_SEMANTIC_SEARCH=true in OAuth mode:
# ✅ Background operations enabled automatically
# ✅ Refresh token storage enabled automatically
# ✅ OAuth credentials required (DCR or pre-registered)
# ✅ Encryption key required for token storage
#
# You only need to set ENABLE_SEMANTIC_SEARCH and provide the required
# infrastructure (Qdrant, Ollama, encryption key). The rest is automatic!
# For more advanced configuration, see env.sample
-37
View File
@@ -1,37 +0,0 @@
# ============================================
# SINGLE-USER BASICAUTH QUICK START
# ============================================
# Simplest deployment mode - one user, credentials in environment
# Use for: Personal instances, local development, testing
#
# Copy this file to .env and fill in your credentials
# ===== REQUIRED SETTINGS =====
# Your Nextcloud instance URL (without trailing slash)
NEXTCLOUD_HOST=http://localhost:8080
# Your Nextcloud credentials
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
# ===== OPTIONAL: EXPLICIT MODE DECLARATION =====
# Recommended to avoid ambiguity
MCP_DEPLOYMENT_MODE=single_user_basic
# ===== OPTIONAL: SEMANTIC SEARCH =====
# Uncomment to enable AI-powered semantic search
# Requires: Qdrant + embedding provider (Ollama or Bedrock)
#
#ENABLE_SEMANTIC_SEARCH=true
#QDRANT_LOCATION=:memory:
#OLLAMA_BASE_URL=http://ollama:11434
#OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# ===== OPTIONAL: DOCUMENT PROCESSING =====
# Extract text from PDFs, images, DOCX for semantic search
#ENABLE_DOCUMENT_PROCESSING=true
#ENABLE_UNSTRUCTURED=true
#UNSTRUCTURED_API_URL=http://unstructured:8000
# That's it! Single-user mode is the simplest to configure.
# For more options, see env.sample
@@ -1,50 +0,0 @@
"""Add app_passwords table for multi-user BasicAuth mode
This migration adds support for storing app passwords that are provisioned
via Astrolabe's personal settings. This enables background sync in
multi-user BasicAuth mode without requiring OAuth.
Revision ID: 002
Revises: 001
Create Date: 2026-01-13 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "002"
down_revision = "001"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add app_passwords table for multi-user BasicAuth mode."""
# App passwords table for multi-user BasicAuth background sync
op.execute(
"""
CREATE TABLE IF NOT EXISTS app_passwords (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# Index for efficient user lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
def downgrade() -> None:
"""Drop app_passwords table."""
op.execute("DROP INDEX IF EXISTS idx_app_passwords_updated")
op.execute("DROP TABLE IF EXISTS app_passwords")
@@ -1,95 +0,0 @@
"""Add scopes and login flow sessions for Login Flow v2
This migration adds support for:
1. Scoped app passwords (scopes column + username column on app_passwords)
2. Login Flow v2 session tracking (login_flow_sessions table)
Nullable scopes preserves backward compat: NULL = legacy app password = all scopes allowed.
Revision ID: 003
Revises: 002
Create Date: 2026-02-27 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add scopes/username to app_passwords and create login_flow_sessions."""
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
"""
)
# Add username column (Nextcloud loginName from Login Flow v2)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN username TEXT
"""
)
# Login Flow v2 session tracking
op.execute(
"""
CREATE TABLE IF NOT EXISTS login_flow_sessions (
user_id TEXT PRIMARY KEY,
encrypted_poll_token BLOB NOT NULL,
poll_endpoint TEXT NOT NULL,
requested_scopes TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
# Index for efficient cleanup of expired sessions
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
ON login_flow_sessions(expires_at)
"""
)
def downgrade() -> None:
"""Drop login_flow_sessions and remove added columns."""
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
# SQLite doesn't support DROP COLUMN before 3.35.0
# Recreate app_passwords without the new columns
op.execute(
"""
CREATE TABLE app_passwords_backup (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
"""
)
op.execute("DROP TABLE app_passwords")
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
-80
View File
@@ -3,84 +3,4 @@
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.
This package is organized into modules by domain:
- management.py: Server status, user sessions, shared helpers
- passwords.py: App password provisioning for multi-user BasicAuth
- webhooks.py: Webhook registration management
- visualization.py: Search and PDF visualization endpoints
"""
from nextcloud_mcp_server.api.access import (
get_user_access,
list_supported_scopes,
update_user_scopes,
)
# Re-export all public functions for backward compatibility
from nextcloud_mcp_server.api.management import (
__version__,
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
get_server_status,
get_user_session,
get_vector_sync_status,
revoke_user_access,
validate_token_and_get_user,
)
from nextcloud_mcp_server.api.passwords import (
delete_app_password,
get_app_password_status,
provision_app_password,
)
from nextcloud_mcp_server.api.visualization import (
get_chunk_context,
get_pdf_preview,
unified_search,
vector_search,
)
from nextcloud_mcp_server.api.webhooks import (
create_webhook,
delete_webhook,
get_installed_apps,
list_webhooks,
)
__all__ = [
# Access endpoints (from access.py)
"get_user_access",
"update_user_scopes",
"list_supported_scopes",
# Version
"__version__",
# Shared helpers (from management.py)
"extract_bearer_token",
"validate_token_and_get_user",
"_sanitize_error_for_client",
"_parse_int_param",
"_parse_float_param",
"_validate_query_string",
# Status endpoints (from management.py)
"get_server_status",
"get_vector_sync_status",
# Session endpoints (from management.py)
"get_user_session",
"revoke_user_access",
# Password endpoints (from passwords.py)
"provision_app_password",
"get_app_password_status",
"delete_app_password",
# Webhook endpoints (from webhooks.py)
"get_installed_apps",
"list_webhooks",
"create_webhook",
"delete_webhook",
# Visualization endpoints (from visualization.py)
"unified_search",
"vector_search",
"get_chunk_context",
"get_pdf_preview",
]
-173
View File
@@ -1,173 +0,0 @@
"""Access and scope management API endpoints.
Provides REST API endpoints for querying and managing user access status
and application-level scopes for Login Flow v2 mode.
"""
import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.api.passwords import (
_extract_basic_auth,
_get_app_password_storage,
)
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
logger = logging.getLogger(__name__)
async def get_user_access(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes.
Returns the user's current provisioning status, granted scopes, and metadata.
Requires BasicAuth with the user's credentials.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
data = await storage.get_app_password_with_scopes(username)
if data is None:
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": False,
"scopes": None,
"username": None,
}
)
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": True,
"scopes": data["scopes"],
"username": data.get("username"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_user_access")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def update_user_scopes(request: Request) -> JSONResponse:
"""PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes.
Accepts JSON body with:
- scopes: list[str] - New scope set to apply
This only updates the stored scopes, not the app password itself.
The app password remains valid; scope enforcement is application-level.
Security note: This endpoint allows direct scope modification without
re-authenticating via Login Flow. The caller must authenticate with
valid BasicAuth credentials (user_id + app_password), which serves
as the authorization check.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
body = await request.json()
except Exception:
return JSONResponse(
{"success": False, "error": "Invalid JSON body"},
status_code=400,
)
scopes = body.get("scopes")
if scopes is None or not isinstance(scopes, list):
return JSONResponse(
{"success": False, "error": "scopes must be a list of strings"},
status_code=400,
)
# Validate scopes
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
return JSONResponse(
{
"success": False,
"error": f"Invalid scopes: {', '.join(invalid)}",
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
},
status_code=400,
)
try:
storage = await _get_app_password_storage(request)
existing = await storage.get_app_password_with_scopes(username)
if existing is None:
return JSONResponse(
{
"success": False,
"error": "No app password provisioned for this user",
},
status_code=404,
)
# Update scopes only (no decrypt/re-encrypt of the password)
await storage.update_app_password_scopes(
user_id=username,
scopes=scopes,
)
# Invalidate scope cache so subsequent tool calls see updated scopes
invalidate_scope_cache(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"scopes": scopes,
"message": "Scopes updated successfully",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "update_user_scopes")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def list_supported_scopes(_: Request) -> JSONResponse:
"""GET /api/v1/scopes - List all supported application-level scopes."""
return JSONResponse(
{
"success": True,
"scopes": sorted(ALL_SUPPORTED_SCOPES),
}
)
File diff suppressed because it is too large Load Diff
-441
View File
@@ -1,441 +0,0 @@
"""App password management API endpoints.
Provides REST API endpoints for app password provisioning in multi-user BasicAuth mode.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- Store app passwords for background sync operations
- Check app password status
- Delete stored app passwords
Authentication is via BasicAuth with the user's Nextcloud credentials.
Passwords are validated against Nextcloud before being stored.
"""
import base64
import logging
import re
import time
from collections import defaultdict
import httpx
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# App password format regex (Nextcloud format: xxxxx-xxxxx-xxxxx-xxxxx-xxxxx)
APP_PASSWORD_PATTERN = re.compile(
r"^[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{5}$"
)
# Timeout for Nextcloud API validation requests (seconds)
NEXTCLOUD_VALIDATION_TIMEOUT = 10.0
# Rate limiting configuration for app password provisioning
# Limits: 5 attempts per user per hour
RATE_LIMIT_MAX_ATTEMPTS = 5
RATE_LIMIT_WINDOW_SECONDS = 3600 # 1 hour
# In-memory rate limiter storage
# Structure: {user_id: [(timestamp, success), ...]}
_rate_limit_attempts: dict[str, list[tuple[float, bool]]] = defaultdict(list)
def _check_rate_limit(user_id: str) -> tuple[bool, int]:
"""Check if user is rate limited for app password operations.
Implements a sliding window rate limiter to prevent brute-force attacks
on the app password provisioning endpoint.
Args:
user_id: User identifier to check
Returns:
Tuple of (is_allowed, seconds_until_retry)
- is_allowed: True if request should be allowed
- seconds_until_retry: Seconds to wait if rate limited (0 if allowed)
"""
current_time = time.time()
window_start = current_time - RATE_LIMIT_WINDOW_SECONDS
# Clean up old attempts outside the window
_rate_limit_attempts[user_id] = [
(ts, success)
for ts, success in _rate_limit_attempts[user_id]
if ts > window_start
]
# Count recent attempts (both successful and failed)
recent_attempts = len(_rate_limit_attempts[user_id])
if recent_attempts >= RATE_LIMIT_MAX_ATTEMPTS:
# Find when the oldest attempt in the window will expire
oldest_attempt = min(ts for ts, _ in _rate_limit_attempts[user_id])
seconds_until_retry = int(
oldest_attempt + RATE_LIMIT_WINDOW_SECONDS - current_time
)
return False, max(1, seconds_until_retry)
return True, 0
def _record_rate_limit_attempt(user_id: str, success: bool) -> None:
"""Record an app password provisioning attempt for rate limiting.
Args:
user_id: User identifier
success: Whether the attempt was successful
"""
_rate_limit_attempts[user_id].append((time.time(), success))
def _extract_basic_auth(
request: Request, path_user_id: str
) -> tuple[str, str, JSONResponse | None]:
"""Extract and validate BasicAuth credentials from request.
Validates:
1. Authorization header is present and valid BasicAuth format
2. Username in credentials matches the path user_id
Args:
request: Starlette request with Authorization header
path_user_id: User ID from the URL path to verify against
Returns:
Tuple of (username, password, error_response)
- If successful: (username, password, None)
- If failed: ("", "", JSONResponse with error)
"""
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Basic "):
return (
"",
"",
JSONResponse(
{"success": False, "error": "Missing BasicAuth credentials"},
status_code=401,
),
)
try:
# Decode BasicAuth
encoded = auth_header.split(" ", 1)[1]
decoded = base64.b64decode(encoded).decode("utf-8")
username, password = decoded.split(":", 1)
except Exception:
return (
"",
"",
JSONResponse(
{"success": False, "error": "Invalid BasicAuth format"},
status_code=401,
),
)
# Verify username matches path user_id
if username != path_user_id:
logger.warning(
f"Username mismatch in app password operation for path user {path_user_id}"
)
return (
"",
"",
JSONResponse(
{"success": False, "error": "Username does not match path user_id"},
status_code=403,
),
)
return username, password, None
async def _get_app_password_storage(request: Request) -> RefreshTokenStorage:
"""Get or initialize RefreshTokenStorage for app password operations.
Checks app.state.storage first, then falls back to creating from environment.
This helper avoids repeated storage initialization logic across endpoints.
Args:
request: Starlette request with app state
Returns:
Initialized RefreshTokenStorage instance
"""
storage = getattr(request.app.state, "storage", None)
if not storage:
# Multi-user BasicAuth mode may not have oauth_context
# Initialize storage from environment
storage = RefreshTokenStorage.from_env()
await storage.initialize()
return storage
async def provision_app_password(request: Request) -> JSONResponse:
"""POST /api/v1/users/{user_id}/app-password - Store app password for background sync.
This endpoint is used by Astrolabe (Nextcloud PHP app) to provision app passwords
for multi-user BasicAuth mode background sync.
The request must include BasicAuth credentials where:
- username: Nextcloud user ID (must match path user_id)
- password: The app password being provisioned
The MCP server validates the app password against Nextcloud before storing it.
This proves the user owns the password and has access to Nextcloud.
Security model:
- User identity is verified via BasicAuth against Nextcloud
- App password is encrypted before storage
- Only the user who owns the password can provision it
- Rate limited to prevent brute-force attacks
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Check rate limit before processing
is_allowed, retry_after = _check_rate_limit(path_user_id)
if not is_allowed:
logger.warning(
f"Rate limit exceeded for app password provisioning: {path_user_id}"
)
return JSONResponse(
{
"success": False,
"error": f"Rate limit exceeded. Try again in {retry_after} seconds.",
},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
# Extract and validate BasicAuth credentials
username, app_password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
_record_rate_limit_attempt(path_user_id, success=False)
return error_response
# Validate app password format
if not APP_PASSWORD_PATTERN.match(app_password):
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password format"},
status_code=400,
)
# Get Nextcloud host from settings
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
logger.error("NEXTCLOUD_HOST not configured")
return JSONResponse(
{"success": False, "error": "Server not configured"},
status_code=500,
)
# Validate app password against Nextcloud
try:
async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
# Use OCS API to verify credentials
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, app_password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
logger.warning(
f"App password validation failed for user: HTTP {response.status_code}"
)
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "Invalid app password"},
status_code=401,
)
# Verify the user ID from response matches
data = response.json()
ocs_user_id = data.get("ocs", {}).get("data", {}).get("id")
if ocs_user_id != username:
logger.warning("User ID mismatch in OCS response")
_record_rate_limit_attempt(path_user_id, success=False)
return JSONResponse(
{"success": False, "error": "User ID mismatch"},
status_code=403,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate app password: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
# Parse optional scopes and username from request body
scopes = None
nc_username = None
try:
body = await request.json()
scopes = body.get("scopes") # list[str] | None
nc_username = body.get("username") # Nextcloud loginName
except Exception:
pass # No JSON body = legacy call without scopes
# Store the validated app password
try:
storage = await _get_app_password_storage(request)
await storage.store_app_password_with_scopes(
username, app_password, scopes=scopes, username=nc_username
)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password stored for {username}",
"scopes": scopes,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "provision_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def get_app_password_status(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/app-password - Check if user has provisioned app password.
Returns status of background sync access for multi-user BasicAuth mode.
Requires BasicAuth with the user's app password for authentication.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
app_password = await storage.get_app_password(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"has_app_password": app_password is not None,
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_app_password_status")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def delete_app_password(request: Request) -> JSONResponse:
"""DELETE /api/v1/users/{user_id}/app-password - Delete stored app password.
Removes the user's app password from MCP server storage.
Requires BasicAuth with the user's credentials.
"""
# Get user_id from path
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
# Extract and validate BasicAuth credentials
username, password, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
# Validate credentials against Nextcloud
settings = get_settings()
nextcloud_host = settings.nextcloud_host
try:
async with nextcloud_httpx_client(
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
) as client:
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
response = await client.get(
test_url,
auth=(username, password),
params={"format": "json"},
headers={"OCS-APIRequest": "true"},
)
if response.status_code != 200:
return JSONResponse(
{"success": False, "error": "Invalid credentials"},
status_code=401,
)
except httpx.RequestError as e:
logger.error(f"Failed to validate credentials: {e}")
return JSONResponse(
{"success": False, "error": "Failed to validate credentials"},
status_code=500,
)
try:
storage = await _get_app_password_storage(request)
deleted = await storage.delete_app_password(username)
if deleted:
logger.info(f"Deleted app password for user: {username}")
return JSONResponse(
{
"success": True,
"message": f"App password deleted for {username}",
}
)
else:
return JSONResponse(
{
"success": True,
"message": "No app password found to delete",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "delete_app_password")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
-776
View File
@@ -1,776 +0,0 @@
"""Visualization API endpoints for search and PDF preview.
ADR-018: Provides REST API endpoints for the Nextcloud PHP app (Astrolabe) to:
- Execute unified search with semantic/BM25/hybrid algorithms
- Execute vector search with PCA visualization coordinates
- Fetch chunk context with surrounding text
- Render PDF pages server-side (avoiding CSP/worker issues)
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import base64
import logging
from typing import Any
import pymupdf
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_parse_float_param,
_parse_int_param,
_sanitize_error_for_client,
_validate_query_string,
extract_bearer_token,
validate_token_and_get_user,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding.service import get_embedding_service
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
logger = logging.getLogger(__name__)
async def unified_search(request: Request) -> JSONResponse:
"""POST /api/v1/search - Search endpoint for Nextcloud Unified Search.
Optimized search endpoint for the Nextcloud Unified Search provider
and other PHP app integrations. Returns results with metadata needed
for navigation to source documents.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 20, // max: 100
"offset": 0, // pagination offset
"include_pca": false, // optional PCA coordinates
"include_chunks": true // include text snippets
}
Response:
{
"results": [{
"id": "doc123",
"doc_type": "note",
"title": "Document Title",
"excerpt": "Matching text snippet...",
"score": 0.85,
"path": "/path/to/file.txt", // for files
"board_id": 1, // for deck cards
"card_id": 42
}],
"total_found": 150,
"algorithm_used": "hybrid"
}
Requires OAuth bearer token for user filtering.
"""
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
# Validate and parse parameters
try:
query = body.get("query", "")
_validate_query_string(query, max_length=10000)
limit = _parse_int_param(
str(body.get("limit")) if body.get("limit") is not None else None,
20,
1,
100,
"limit",
)
offset = _parse_int_param(
str(body.get("offset")) if body.get("offset") is not None else None,
0,
0,
1000000,
"offset",
)
score_threshold = _parse_float_param(
body.get("score_threshold"),
0.0,
0.0,
1.0,
"score_threshold",
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
include_pca = body.get("include_pca", False)
include_chunks = body.get("include_chunks", True)
doc_types = body.get("doc_types") # Optional filter
if not query:
return JSONResponse({"results": [], "total_found": 0})
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Request extra results to handle offset
search_limit = limit + offset
# Execute search
all_results = []
if doc_types and isinstance(doc_types, list):
for doc_type in doc_types:
if doc_type:
results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
doc_type=doc_type,
)
all_results.extend(results)
all_results.sort(key=lambda r: r.score, reverse=True)
else:
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=search_limit,
)
# Sort results by score (no deduplication - show all chunks)
sorted_results = sorted(all_results, key=lambda r: r.score, reverse=True)
# Calculate total and apply pagination
total_found = len(sorted_results)
paginated_results = sorted_results[offset : offset + limit]
# Format results for Unified Search
formatted_results = []
for result in paginated_results:
# Get document ID (prefer note_id for notes)
doc_id = result.id
if result.metadata and "note_id" in result.metadata:
doc_id = result.metadata["note_id"]
result_data: dict[str, Any] = {
"id": doc_id,
"doc_type": result.doc_type,
"title": result.title,
"score": result.score,
}
# Include excerpt/chunk if requested (full content, no truncation)
if include_chunks and result.excerpt:
result_data["excerpt"] = result.excerpt
# Include navigation metadata from result.metadata
if result.metadata:
# File path and mimetype for files
if "path" in result.metadata:
result_data["path"] = result.metadata["path"]
if "mime_type" in result.metadata:
result_data["mime_type"] = result.metadata["mime_type"]
# Deck card navigation
if "board_id" in result.metadata:
result_data["board_id"] = result.metadata["board_id"]
if "card_id" in result.metadata:
result_data["card_id"] = result.metadata["card_id"]
# Calendar event metadata
if "calendar_id" in result.metadata:
result_data["calendar_id"] = result.metadata["calendar_id"]
if "event_uid" in result.metadata:
result_data["event_uid"] = result.metadata["event_uid"]
# Add PDF page metadata
if result.page_number is not None:
result_data["page_number"] = result.page_number
if result.page_count is not None:
result_data["page_count"] = result.page_count
# Add chunk metadata (always present, defaults to 0 and 1)
result_data["chunk_index"] = result.chunk_index
result_data["total_chunks"] = result.total_chunks
# Add chunk offsets for modal navigation
if result.chunk_start_offset is not None:
result_data["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
result_data["chunk_end_offset"] = result.chunk_end_offset
formatted_results.append(result_data)
response_data: dict[str, Any] = {
"results": formatted_results,
"total_found": total_found,
"algorithm_used": algorithm,
}
# Optional PCA coordinates
if include_pca and len(paginated_results) >= 2:
try:
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(
paginated_results, query_embedding
)
response_data["pca_data"] = pca_data
except Exception as e:
logger.warning(f"Failed to compute PCA for unified search: {e}")
return JSONResponse(response_data)
except Exception as e:
logger.error(f"Error in unified search: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "unified_search"),
},
status_code=500,
)
async def vector_search(request: Request) -> JSONResponse:
"""POST /api/v1/vector-viz/search - Vector search for visualization.
Executes semantic search and returns results with optional PCA coordinates
for 2D visualization.
Request body:
{
"query": "search query",
"algorithm": "semantic|bm25|hybrid", // default: hybrid
"limit": 10, // max: 50
"include_pca": true, // whether to include 2D coordinates
"doc_types": ["note", "file"] // optional filter by document types
}
Requires OAuth bearer token for user filtering.
"""
settings = get_settings()
if not settings.vector_sync_enabled:
return JSONResponse(
{"error": "Vector sync is disabled on this server"},
status_code=404,
)
# Validate OAuth token and extract user
try:
user_id, _validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/vector-viz/search: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "vector_search"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
query = body.get("query", "")
algorithm = body.get("algorithm", "hybrid")
fusion = body.get("fusion", "rrf")
score_threshold = body.get("score_threshold", 0.0)
limit = min(body.get("limit", 10), 50) # Enforce max limit
include_pca = body.get("include_pca", True)
doc_types = body.get("doc_types") # Optional list of document types
if not query:
return JSONResponse(
{"error": "Missing required parameter: query"},
status_code=400,
)
# Validate algorithm
valid_algorithms = {"semantic", "bm25", "hybrid"}
if algorithm not in valid_algorithms:
algorithm = "hybrid"
# Validate fusion method
valid_fusions = {"rrf", "dbsf"}
if fusion not in valid_fusions:
fusion = "rrf"
# Select search algorithm
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
else:
# Both "hybrid" and "bm25" use the BM25HybridSearchAlgorithm
# which combines dense semantic and sparse BM25 vectors
search_algo = BM25HybridSearchAlgorithm(
score_threshold=score_threshold, fusion=fusion
)
# Execute search for each doc_type if specified, otherwise search all
all_results = []
if doc_types and isinstance(doc_types, list):
# Search each doc_type separately and merge results
for doc_type in doc_types:
if doc_type: # Skip empty strings
results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
doc_type=doc_type,
)
all_results.extend(results)
# Sort merged results by score and limit
all_results.sort(key=lambda r: r.score, reverse=True)
all_results = all_results[:limit]
else:
# Search all document types
all_results = await search_algo.search(
query=query,
user_id=user_id,
limit=limit,
)
# Format results for PHP client
formatted_results = []
for result in all_results:
formatted_result = {
"id": result.id,
"doc_type": result.doc_type,
"title": result.title,
"excerpt": result.excerpt[:200] if result.excerpt else "",
"score": result.score,
"metadata": result.metadata,
# Chunk information for context display
"chunk_index": result.chunk_index,
"total_chunks": result.total_chunks,
}
# Include optional fields if present
if result.chunk_start_offset is not None:
formatted_result["chunk_start_offset"] = result.chunk_start_offset
if result.chunk_end_offset is not None:
formatted_result["chunk_end_offset"] = result.chunk_end_offset
if result.page_number is not None:
formatted_result["page_number"] = result.page_number
if result.page_count is not None:
formatted_result["page_count"] = result.page_count
formatted_results.append(formatted_result)
response_data: dict[str, Any] = {
"results": formatted_results,
"algorithm_used": algorithm,
"total_documents": len(formatted_results),
}
# Compute PCA coordinates for visualization using shared function
if include_pca and len(all_results) >= 2:
try:
# Get query embedding from search algorithm or generate it
if search_algo.query_embedding is not None:
query_embedding = search_algo.query_embedding
else:
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
pca_data = await compute_pca_coordinates(all_results, query_embedding)
response_data["coordinates_3d"] = pca_data["coordinates_3d"]
response_data["query_coords"] = pca_data["query_coords"]
if "pca_variance" in pca_data:
response_data["pca_variance"] = pca_data["pca_variance"]
except Exception as e:
logger.warning(f"Failed to compute PCA coordinates: {e}")
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
elif include_pca:
# Not enough results for PCA
response_data["coordinates_3d"] = []
response_data["query_coords"] = []
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "vector_search")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_chunk_context(request: Request) -> JSONResponse:
"""GET /api/v1/chunk-context - Fetch chunk text with context.
Retrieves the matched chunk along with surrounding text and metadata.
Used by clients to display chunk context and highlighted PDFs.
Query parameters:
doc_type: Document type (e.g., "note")
doc_id: Document ID
start: Chunk start offset (character position)
end: Chunk end offset (character position)
context: Characters of context before/after (default: 500)
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/chunk-context: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_chunk_context"),
},
status_code=401,
)
try:
# Get query parameters
doc_type = request.query_params.get("doc_type")
doc_id = request.query_params.get("doc_id")
start_str = request.query_params.get("start")
end_str = request.query_params.get("end")
# Validate required parameters
if not all([doc_type, doc_id, start_str, end_str]):
return JSONResponse(
{
"success": False,
"error": "Missing required parameters: doc_type, doc_id, start, end",
},
status_code=400,
)
# Type narrowing: we already checked these are not None above
assert start_str is not None
assert end_str is not None
assert doc_id is not None
assert doc_type is not None
# Parse and validate integer parameters with bounds checking
try:
context_chars = _parse_int_param(
request.query_params.get("context"),
500,
0,
10000,
"context_chars",
)
start = _parse_int_param(start_str, 0, 0, 10000000, "start")
end = _parse_int_param(end_str, 0, 0, 10000000, "end")
if end <= start:
raise ValueError("end must be greater than start")
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Convert doc_id to int if possible (most IDs are int)
doc_id_val: str | int = int(doc_id) if doc_id.isdigit() else doc_id
# Get bearer token for client initialization
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Initialize authenticated Nextcloud client
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
chunk_context = await get_chunk_with_context(
nc_client=nc_client,
user_id=user_id,
doc_id=doc_id_val,
doc_type=doc_type,
chunk_start=start,
chunk_end=end,
context_chars=context_chars,
)
if chunk_context is None:
return JSONResponse(
{
"success": False,
"error": f"Failed to fetch chunk context for {doc_type} {doc_id}",
},
status_code=404,
)
# For PDF files, also fetch the highlighted page image from Qdrant if available
# This is useful for clients that want to show a pre-rendered image
highlighted_page_image = None
page_number = chunk_context.page_number
if doc_type == "file":
try:
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Query for this specific chunk's highlighted image
points_response = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
get_placeholder_filter(),
FieldCondition(
key="doc_id", match=MatchValue(value=doc_id_val)
),
FieldCondition(
key="user_id", match=MatchValue(value=user_id)
),
FieldCondition(
key="chunk_start_offset", match=MatchValue(value=start)
),
FieldCondition(
key="chunk_end_offset", match=MatchValue(value=end)
),
]
),
limit=1,
with_vectors=False,
with_payload=["highlighted_page_image", "page_number"],
)
if points_response[0]:
payload = points_response[0][0].payload
if payload:
highlighted_page_image = payload.get("highlighted_page_image")
# Trust Qdrant page number if available (might be more accurate than context expansion logic)
if payload.get("page_number") is not None:
page_number = payload.get("page_number")
except Exception as e:
logger.warning(f"Failed to fetch highlighted image: {e}")
# Build response
response_data = {
"success": True,
"chunk_text": chunk_context.chunk_text,
"before_context": chunk_context.before_context,
"after_context": chunk_context.after_context,
"has_more_before": chunk_context.has_before_truncation,
"has_more_after": chunk_context.has_after_truncation,
"page_number": page_number,
"chunk_index": chunk_context.chunk_index,
"total_chunks": chunk_context.total_chunks,
}
if highlighted_page_image:
response_data["highlighted_page_image"] = highlighted_page_image
return JSONResponse(response_data)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_chunk_context")
return JSONResponse(
{"error": error_msg},
status_code=500,
)
async def get_pdf_preview(request: Request) -> JSONResponse:
"""GET /api/v1/pdf-preview - Render PDF page to PNG image.
Server-side PDF rendering using PyMuPDF. This endpoint allows Astrolabe
to display PDF pages without requiring client-side PDF.js, avoiding CSP
worker restrictions and ES private field issues in Chromium.
Query parameters:
file_path: WebDAV path to PDF file (e.g., "/Documents/report.pdf")
page: Page number (1-indexed, default: 1)
scale: Zoom factor for rendering (default: 2.0 = 144 DPI)
Returns:
{
"success": true,
"image": "<base64-encoded-png>",
"page_number": 1,
"total_pages": 10
}
Requires OAuth bearer token for authentication.
"""
# Log incoming request
file_path_param = request.query_params.get("file_path", "<not provided>")
page_param = request.query_params.get("page", "1")
logger.info(f"PDF preview request: file_path={file_path_param}, page={page_param}")
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
logger.info(f"PDF preview authenticated for user: {user_id}")
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/pdf-preview: {e}")
return JSONResponse(
{
"success": False,
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_pdf_preview"),
},
status_code=401,
)
try:
# Parse and validate parameters
file_path = request.query_params.get("file_path")
if not file_path:
return JSONResponse(
{"success": False, "error": "Missing required parameter: file_path"},
status_code=400,
)
# Validate no path traversal sequences
if ".." in file_path:
return JSONResponse(
{"success": False, "error": "Invalid file path"},
status_code=400,
)
try:
page_num = _parse_int_param(
request.query_params.get("page"), 1, 1, 10000, "page"
)
scale = _parse_float_param(
request.query_params.get("scale"), 2.0, 0.5, 5.0, "scale"
)
except ValueError as e:
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
# Get bearer token for WebDAV authentication
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing token")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Download PDF via WebDAV using user's token
async with NextcloudClient.from_token(
base_url=nextcloud_host, token=token, username=user_id
) as nc_client:
pdf_bytes, _ = await nc_client.webdav.read_file(file_path)
# Check file size limit (50 MB)
max_pdf_size = 50 * 1024 * 1024
if len(pdf_bytes) > max_pdf_size:
return JSONResponse(
{
"success": False,
"error": f"PDF file exceeds maximum size limit ({max_pdf_size // (1024 * 1024)} MB)",
},
status_code=413,
)
# Render page with PyMuPDF
doc = pymupdf.open(stream=pdf_bytes, filetype="pdf")
try:
total_pages = doc.page_count
# Validate page number
if page_num > total_pages:
return JSONResponse(
{
"success": False,
"error": f"Page {page_num} does not exist (document has {total_pages} pages)",
},
status_code=400,
)
page = doc[page_num - 1] # 0-indexed
mat = pymupdf.Matrix(scale, scale)
pix = page.get_pixmap(matrix=mat, alpha=False)
png_bytes = pix.tobytes("png")
finally:
doc.close()
# Encode as base64
image_b64 = base64.b64encode(png_bytes).decode("ascii")
logger.info(
f"Rendered PDF preview: {file_path} page {page_num}/{total_pages}, "
f"{len(png_bytes):,} bytes"
)
return JSONResponse(
{
"success": True,
"image": image_b64,
"page_number": page_num,
"total_pages": total_pages,
}
)
except FileNotFoundError:
logger.warning(f"PDF file not found: {file_path_param}")
return JSONResponse(
{"success": False, "error": "PDF file not found"},
status_code=404,
)
except (pymupdf.FileDataError, pymupdf.EmptyFileError):
logger.warning(f"Invalid or corrupted PDF file: {file_path_param}")
return JSONResponse(
{"success": False, "error": "Invalid or corrupted PDF file"},
status_code=400,
)
except Exception as e:
logger.error(f"PDF preview error: {e}", exc_info=True)
error_msg = _sanitize_error_for_client(e, "get_pdf_preview")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
-304
View File
@@ -1,304 +0,0 @@
"""Webhook management API endpoints.
Provides REST API endpoints for managing webhook registrations with Nextcloud.
These endpoints are used by the Nextcloud PHP app (Astrolabe) to:
- List installed Nextcloud apps
- Create, list, and delete webhook registrations
All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier.
"""
import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import (
_sanitize_error_for_client,
extract_bearer_token,
validate_token_and_get_user,
)
from nextcloud_mcp_server.client.webhooks import WebhooksClient
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def get_installed_apps(request: Request) -> JSONResponse:
"""GET /api/v1/apps - Get list of installed Nextcloud apps.
Returns a list of installed app IDs for filtering webhook presets.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/apps: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=401,
)
try:
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Get installed apps using OCS API
# Notes, Calendar, Deck, Tables, etc. are apps that support webhooks
# We check which ones are installed and enabled
ocs_url = "/ocs/v1.php/cloud/apps"
params = {"filter": "enabled"}
response = await client.get(
ocs_url,
params=params,
headers={"OCS-APIRequest": "true", "Accept": "application/json"},
)
if response.status_code != 200:
raise ValueError(f"OCS API returned status {response.status_code}")
data = response.json()
apps = data.get("ocs", {}).get("data", {}).get("apps", [])
return JSONResponse({"apps": apps})
except Exception as e:
logger.error(f"Error getting installed apps for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "get_installed_apps"),
},
status_code=500,
)
async def list_webhooks(request: Request) -> JSONResponse:
"""GET /api/v1/webhooks - List all registered webhooks.
Returns list of webhook registrations for the authenticated user.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=401,
)
try:
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to list webhooks
webhooks_client = WebhooksClient(client, user_id)
webhooks = await webhooks_client.list_webhooks()
return JSONResponse({"webhooks": webhooks})
except Exception as e:
logger.error(f"Error listing webhooks for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "list_webhooks"),
},
status_code=500,
)
async def create_webhook(request: Request) -> JSONResponse:
"""POST /api/v1/webhooks - Create a new webhook registration.
Request body:
{
"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent",
"uri": "http://mcp:8000/webhooks/nextcloud",
"eventFilter": {"event.node.path": "/^\\/.*\\/files\\/Notes\\//"}
}
Returns the created webhook data including the webhook ID.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=401,
)
try:
# Parse request body
body = await request.json()
event = body.get("event")
uri = body.get("uri")
# Accept both camelCase (eventFilter) and snake_case (event_filter)
event_filter = body.get("eventFilter") or body.get("event_filter")
if not event or not uri:
return JSONResponse(
{
"error": "Bad request",
"message": "Missing required fields: event, uri",
},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to create webhook
webhooks_client = WebhooksClient(client, user_id)
webhook_data = await webhooks_client.create_webhook(
event=event, uri=uri, event_filter=event_filter
)
return JSONResponse({"webhook": webhook_data})
except Exception as e:
logger.error(f"Error creating webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "create_webhook"),
},
status_code=500,
)
async def delete_webhook(request: Request) -> JSONResponse:
"""DELETE /api/v1/webhooks/{webhook_id} - Delete a webhook registration.
Returns success/failure status.
Requires OAuth bearer token for authentication.
"""
try:
# Validate OAuth token and extract user
user_id, validated = await validate_token_and_get_user(request)
except Exception as e:
logger.warning(f"Unauthorized access to /api/v1/webhooks: {e}")
return JSONResponse(
{
"error": "Unauthorized",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=401,
)
try:
# Get webhook_id from path parameter
webhook_id = request.path_params.get("webhook_id")
if not webhook_id:
return JSONResponse(
{"error": "Bad request", "message": "Missing webhook_id"},
status_code=400,
)
try:
webhook_id = int(webhook_id)
except ValueError:
return JSONResponse(
{"error": "Bad request", "message": "Invalid webhook_id"},
status_code=400,
)
# Get Bearer token from request
token = extract_bearer_token(request)
if not token:
raise ValueError("Missing Authorization header")
# Get Nextcloud host from OAuth context
oauth_ctx = request.app.state.oauth_context
nextcloud_host = oauth_ctx.get("config", {}).get("nextcloud_host", "")
if not nextcloud_host:
raise ValueError("Nextcloud host not configured")
# Create authenticated HTTP client
async with nextcloud_httpx_client(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {token}"},
timeout=30.0,
) as client:
# Use WebhooksClient to delete webhook
webhooks_client = WebhooksClient(client, user_id)
await webhooks_client.delete_webhook(webhook_id=webhook_id)
return JSONResponse({"success": True, "message": "Webhook deleted"})
except Exception as e:
logger.error(f"Error deleting webhook for user {user_id}: {e}")
return JSONResponse(
{
"error": "Internal error",
"message": _sanitize_error_for_client(e, "delete_webhook"),
},
status_code=500,
)
File diff suppressed because it is too large Load Diff
@@ -1,152 +0,0 @@
"""
Client for querying Astrolabe Management API for background sync credentials.
This client uses OAuth client credentials flow to authenticate to Nextcloud
and retrieve user app passwords for background sync operations.
"""
import logging
import time
from typing import Optional
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
class AstrolabeClient:
"""Client for querying Astrolabe API for background sync credentials.
Uses OAuth client credentials flow to authenticate as the MCP server
and retrieve user app passwords that are stored in Nextcloud.
"""
def __init__(
self,
nextcloud_host: str,
client_id: str,
client_secret: str,
):
"""
Initialize Astrolabe client.
Args:
nextcloud_host: Nextcloud base URL (e.g., https://cloud.example.com)
client_id: OAuth client ID for MCP server
client_secret: OAuth client secret
"""
self.nextcloud_host = nextcloud_host.rstrip("/")
self.client_id = client_id
self.client_secret = client_secret
self._token_cache: Optional[dict] = None # {access_token, expires_at}
async def get_access_token(self) -> str:
"""
Get access token using OAuth client credentials flow.
Tokens are cached with 1-minute early refresh to avoid expiration.
Returns:
Access token string
Raises:
httpx.HTTPError: If token request fails
"""
# Check cache
if self._token_cache and time.time() < self._token_cache["expires_at"]:
logger.debug("Using cached OAuth token for Astrolabe API")
return self._token_cache["access_token"]
# Discover token endpoint
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
async with nextcloud_httpx_client() as client:
logger.debug(f"Discovering token endpoint from {discovery_url}")
discovery_resp = await client.get(discovery_url)
discovery_resp.raise_for_status()
token_endpoint = discovery_resp.json()["token_endpoint"]
logger.debug(f"Requesting client credentials token from {token_endpoint}")
# Request token using client credentials grant
token_resp = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "openid", # Minimal scope
},
)
token_resp.raise_for_status()
data = token_resp.json()
# Cache with 1-minute early refresh
expires_in = data.get("expires_in", 3600)
self._token_cache = {
"access_token": data["access_token"],
"expires_at": time.time() + expires_in - 60,
}
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
return data["access_token"]
async def get_user_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve user's app password for background sync.
Args:
user_id: Nextcloud user ID
Returns:
App password string, or None if user hasn't provisioned
Raises:
httpx.HTTPError: If API request fails (except 404)
"""
token = await self.get_access_token()
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
async with nextcloud_httpx_client() as client:
logger.debug(f"Retrieving app password for user: {user_id}")
response = await client.get(
url,
headers={"Authorization": f"Bearer {token}"},
timeout=10.0,
)
if response.status_code == 404:
logger.debug(f"No app password configured for user: {user_id}")
return None
response.raise_for_status()
data = response.json()
logger.info(
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
)
return data.get("app_password")
async def get_background_sync_status(self, user_id: str) -> dict:
"""
Get background sync status for a user.
Args:
user_id: Nextcloud user ID
Returns:
Dict with keys: has_access, credential_type, provisioned_at
Raises:
httpx.HTTPError: If API request fails
"""
# For now, check if app password exists
# In the future, this could query a dedicated status endpoint
app_password = await self.get_user_app_password(user_id)
return {
"has_access": app_password is not None,
"credential_type": "app_password" if app_password else None,
"provisioned_at": None, # TODO: Get from API if available
}
@@ -8,10 +8,8 @@ import hashlib
import logging
import os
import secrets
import time
from base64 import urlsafe_b64encode
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
import httpx
import jwt
@@ -23,8 +21,6 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_query_idp_userinfo,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -145,7 +141,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
)
# Fetch authorization endpoint
async with nextcloud_httpx_client() as http_client:
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -154,6 +150,8 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
# Replace internal Docker hostname with public URL
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
from urllib.parse import urlparse as parse_url
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -287,7 +285,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with nextcloud_httpx_client() as http_client:
async with httpx.AsyncClient() as http_client:
response = await http_client.post(
oauth_client.token_endpoint,
data=token_params,
@@ -297,12 +295,31 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client:
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
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,
@@ -315,7 +332,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if code_verifier:
token_params["code_verifier"] = code_verifier
async with nextcloud_httpx_client() as http_client:
async with httpx.AsyncClient() as http_client:
response = await http_client.post(
token_endpoint,
data=token_params,
@@ -383,6 +400,8 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
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})"
@@ -10,8 +10,6 @@ import httpx
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -83,7 +81,6 @@ async def register_client(
scopes: str = "openid profile email",
token_type: str | None = "Bearer",
resource_url: str | None = None,
max_retries: int = 3,
) -> ClientInfo:
"""
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
@@ -99,7 +96,6 @@ async def register_client(
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
Set to None to omit this field (required for Keycloak and other standard providers).
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
ClientInfo with registration details
@@ -136,92 +132,58 @@ async def register_client(
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with nextcloud_httpx_client(timeout=30.0) as client:
for attempt in range(max_retries):
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
async with httpx.AsyncClient(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
if response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt)
logger.warning(
f"Rate limited (429) registering client, "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
response.raise_for_status()
client_info = response.json()
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning(
"RFC 7592 fields missing - client deletion may not work"
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get(
"registration_access_token"
),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
logger.error(
f"Failed to register client: HTTP {e.response.status_code}"
)
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(
f"Invalid response from registration endpoint: missing {e}"
)
raise ValueError(f"Invalid registration response: missing {e}")
# Should not reach here, but raise if we do
raise httpx.HTTPStatusError(
"Registration failed after retries",
request=httpx.Request("POST", registration_endpoint),
response=httpx.Response(429),
)
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
async def delete_client(
@@ -267,7 +229,7 @@ async def delete_client(
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with nextcloud_httpx_client(timeout=30.0) as http_client:
async with httpx.AsyncClient(timeout=30.0) as http_client:
for attempt in range(max_retries):
try:
# Prefer RFC 7592 Bearer token authentication
+4 -26
View File
@@ -10,7 +10,6 @@ import logging
import os
from dataclasses import dataclass
from typing import Dict, List, Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
@@ -142,8 +141,8 @@ class ClientRegistry:
if not self._validate_redirect_uri(client, redirect_uri):
return False, f"Invalid redirect_uri for client {client_id}"
# Validate scopes if provided (wildcard "*" allows all scopes)
if scopes and "*" not in client.allowed_scopes:
# Validate scopes if provided
if scopes:
invalid_scopes = set(scopes) - set(client.allowed_scopes)
if invalid_scopes:
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
@@ -162,6 +161,8 @@ class ClientRegistry:
True if valid, False otherwise
"""
# Parse the redirect URI
from urllib.parse import urlparse
parsed = urlparse(redirect_uri)
# Check against registered patterns
@@ -202,29 +203,6 @@ class ClientRegistry:
# In production, would persist to database
return True
def register_proxy_client(
self, client_id: str, redirect_uris: list[str], name: str = ""
) -> None:
"""Register a client discovered via DCR proxy.
When the MCP server acts as an OAuth AS proxy, clients register via
the proxy's /oauth/register endpoint. This method stores the client
locally so /oauth/authorize can validate it.
Args:
client_id: Client identifier from Nextcloud DCR response
redirect_uris: Allowed redirect URIs
name: Optional human-readable name
"""
self._clients[client_id] = MCPClientInfo(
client_id=client_id,
name=name or f"DCR-{client_id[:8]}",
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True,
)
logger.info(f"Registered proxy client: {client_id}")
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
"""
Get client information.
-88
View File
@@ -1,88 +0,0 @@
"""MCP elicitation helpers for Login Flow v2.
Provides a unified way to present login URLs to users, using MCP elicitation
when the client supports it, or falling back to returning the URL in a message.
"""
import logging
from mcp.server.fastmcp import Context
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class LoginFlowConfirmation(BaseModel):
"""Schema for Login Flow v2 confirmation elicitation."""
acknowledged: bool = Field(
default=False,
description="Check this box after completing login at the provided URL",
)
async def present_login_url(
ctx: Context,
login_url: str,
message: str | None = None,
) -> str:
"""Present a login URL to the user via MCP elicitation or message.
Tries MCP elicitation first (ctx.elicit) for interactive clients.
Falls back to returning the URL as a plain message.
Args:
ctx: MCP context
login_url: URL the user should open in their browser
message: Optional custom message (defaults to standard Login Flow prompt)
Returns:
"accepted" if user acknowledged via elicitation,
"declined" if user declined,
"message_only" if elicitation not supported (URL returned in message)
"""
if message is None:
message = (
f"Please log in to Nextcloud to grant access:\n\n"
f"{login_url}\n\n"
f"Open this URL in your browser, log in, and grant the requested permissions. "
f"Then check the box below and click OK."
)
if not hasattr(ctx, "elicit"):
logger.debug(
"Elicitation not available (no elicit method), returning URL in message"
)
return "message_only"
try:
result = await ctx.elicit(
message=message,
schema=LoginFlowConfirmation,
)
if result.action == "accept":
if hasattr(result, "data") and not result.data.acknowledged: # type: ignore[union-attr]
logger.warning(
"User accepted login flow without checking the acknowledged box — "
"login completion will be verified via polling"
)
logger.info("User acknowledged login flow completion")
return "accepted"
elif result.action == "decline":
logger.info("User declined login flow")
return "declined"
else:
logger.info("User cancelled login flow")
return "cancelled"
except NotImplementedError:
# Elicitation not supported by this client/SDK - fall back to message
logger.debug("Elicitation not available, returning URL in message")
return "message_only"
except Exception as e:
logger.warning(
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
"falling back to message"
)
return "message_only"
+2 -4
View File
@@ -8,7 +8,6 @@ Handles OAuth flows with Keycloak as the identity provider, including:
- Integration with RefreshTokenStorage
"""
import base64
import hashlib
import logging
import os
@@ -18,8 +17,6 @@ from urllib.parse import urlencode, urlparse
import httpx
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -109,7 +106,7 @@ class KeycloakOAuthClient:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._http_client is None:
self._http_client = nextcloud_httpx_client(timeout=30.0)
self._http_client = httpx.AsyncClient(timeout=30.0)
return self._http_client
async def close(self) -> None:
@@ -158,6 +155,7 @@ class KeycloakOAuthClient:
Returns:
Tuple of (code_verifier, code_challenge)
"""
import base64
# Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)
-157
View File
@@ -1,157 +0,0 @@
"""Nextcloud Login Flow v2 HTTP client.
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
The flow has two steps:
1. Initiate: POST /index.php/login/v2 → returns login URL + poll endpoint/token
2. Poll: POST to poll endpoint with token → returns server URL, loginName, appPassword
"""
import logging
import ssl
from pydantic import BaseModel, Field
from nextcloud_mcp_server.http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
class LoginFlowInitResponse(BaseModel):
"""Response from initiating Login Flow v2."""
login_url: str = Field(description="URL to present to the user for browser login")
poll_endpoint: str = Field(description="URL to poll for flow completion")
poll_token: str = Field(description="Token to use when polling")
class LoginFlowPollResult(BaseModel):
"""Result of polling Login Flow v2."""
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
login_name: str | None = Field(
None, description="Nextcloud login name (on completion)"
)
app_password: str | None = Field(
None, description="Generated app password (on completion)"
)
class LoginFlowV2Client:
"""HTTP client for Nextcloud Login Flow v2.
This client handles the two-step Login Flow v2 process:
1. Initiate a flow to get a login URL for the user
2. Poll for completion to receive the app password
Args:
nextcloud_host: Base URL of the Nextcloud instance
verify_ssl: SSL verification setting (True, False, or SSLContext)
"""
def __init__(
self,
nextcloud_host: str,
verify_ssl: bool | ssl.SSLContext = True,
):
self.nextcloud_host = nextcloud_host.rstrip("/")
self.verify_ssl = verify_ssl
async def initiate(
self, user_agent: str = "nextcloud-mcp-server"
) -> LoginFlowInitResponse:
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to POST /index.php/login/v2 on the
configured Nextcloud server to start a new login flow.
Args:
user_agent: User-Agent string for the app password name
Returns:
LoginFlowInitResponse with login URL and poll credentials
Raises:
httpx.HTTPStatusError: If the Nextcloud server returns an error
"""
url = f"{self.nextcloud_host}/index.php/login/v2"
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=15.0
) as client:
response = await client.post(
url,
headers={"User-Agent": user_agent},
)
response.raise_for_status()
data = response.json()
poll_data = data.get("poll", {})
try:
result = LoginFlowInitResponse(
login_url=data["login"],
poll_endpoint=poll_data["endpoint"],
poll_token=poll_data["token"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
) from e
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
return result
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to the poll endpoint provided by the
initiate response. Nextcloud returns:
- 200 with credentials when the user completes login
- 404 when still pending
- Other errors for expired/invalid flows
Args:
poll_endpoint: URL to poll (from initiate response)
poll_token: Token for polling (from initiate response)
Returns:
LoginFlowPollResult with status and optional credentials
"""
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=10.0
) as client:
response = await client.post(
poll_endpoint,
data={"token": poll_token},
)
if response.status_code == 200:
data = response.json()
logger.info(
f"Login Flow v2 completed: server={data.get('server')}, "
f"loginName={data.get('loginName')}"
)
try:
return LoginFlowPollResult(
status="completed",
server=data["server"],
login_name=data["loginName"],
app_password=data["appPassword"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
) from e
if response.status_code == 404:
logger.debug("Login Flow v2 still pending")
return LoginFlowPollResult(status="pending")
# Any other status indicates the flow has expired or is invalid
logger.warning(
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
)
return LoginFlowPollResult(status="expired")
File diff suppressed because it is too large Load Diff
@@ -9,13 +9,11 @@ import functools
import logging
from typing import Callable
import jwt
from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
@@ -67,6 +65,8 @@ def require_provisioning(func: Callable) -> Callable:
# Check if we're in token exchange mode - if so, skip provisioning check
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
# Token exchange mode - per-request exchange, no provisioning needed
@@ -78,6 +78,8 @@ def require_provisioning(func: Callable) -> Callable:
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
try:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -161,6 +163,8 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
# Get user_id from authorization token
user_id = None
if hasattr(ctx, "authorization") and ctx.authorization:
import jwt
token = ctx.authorization.token
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub")
@@ -1,7 +1,7 @@
"""Scope-based authorization for MCP tools."""
import logging
import time
import os
from functools import wraps
from typing import Any, Callable
@@ -10,18 +10,8 @@ from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
# Scopes that only assert identity (OIDC standard claims).
# Tools requiring *only* these scopes (e.g. auth provisioning tools) must
# bypass the Login Flow v2 "is the user provisioned?" check — otherwise the
# very tools that *create* app passwords would be blocked for unprovisioned
# users, creating a circular dependency.
IDENTITY_ONLY_SCOPES: frozenset[str] = frozenset({"openid", "profile", "email"})
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
@@ -129,70 +119,21 @@ def require_scopes(*required_scopes: str):
)
if access_token is None:
# No OAuth token — BasicAuth mode bypasses scope checks
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
logger.debug(
f"No access token for {func_name} - allowing (BasicAuth mode)"
f"No access token present for {func_name} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# ── Login Flow v2: Check stored app password scopes ──
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
# identity only. Nextcloud API access uses stored app passwords.
# Check if the user has a stored app password with appropriate scopes.
if get_settings().enable_login_flow and not set(required_scopes).issubset(
IDENTITY_ONLY_SCOPES
):
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
user_id = await extract_user_id_from_token(ctx)
if user_id and user_id != "default_user":
stored_scopes = await _get_stored_scopes(user_id)
if stored_scopes is None:
# No stored app password → require provisioning
error_msg = (
f"Access denied to {func_name}: "
f"Nextcloud access not provisioned. "
f"Please call 'nc_auth_provision_access' first."
)
logger.warning(error_msg)
raise ProvisioningRequiredError(error_msg)
if stored_scopes == "all":
# NULL scopes in DB = legacy app password = all allowed
logger.debug(
f"Stored app password scope check passed for {func_name}: all scopes"
)
return await func(*args, **kwargs)
# Check stored scopes against required
stored_set = set(stored_scopes)
missing = set(required_scopes) - stored_set
if missing:
error_msg = (
f"Access denied to {func_name}: "
f"Missing scopes: {', '.join(sorted(missing))}. "
f"Call 'nc_auth_update_scopes' to add permissions."
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing), error_msg)
logger.debug(
f"Stored app password scope check passed for {func_name}"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
# Check if offline access is enabled
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
settings = get_settings()
enable_offline_access = settings.enable_offline_access
enable_offline_access = (
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
)
# In offline access mode, check if Nextcloud scopes require provisioning
if enable_offline_access:
@@ -473,47 +414,3 @@ def discover_all_scopes(mcp) -> list[str]:
# Return sorted list of unique scopes
return sorted(all_scopes)
# ── Login Flow v2 helpers ────────────────────────────────────────────────
# Scope cache: user_id → (expires_at, scopes)
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
_SCOPE_CACHE_TTL = 300 # 5 minutes
def invalidate_scope_cache(user_id: str) -> None:
"""Remove cached scopes for a user (call when scopes are updated)."""
_scope_cache.pop(user_id, None)
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
"""Look up stored app password scopes for a user (with TTL cache).
Returns:
- list[str]: Specific scopes granted
- "all": NULL scopes in DB (legacy = all allowed)
- None: No stored app password (provisioning required)
Raises:
Storage/infrastructure exceptions propagate to the caller
(require_scopes decorator) for proper MCP error responses.
"""
now = time.time()
if user_id in _scope_cache:
expires_at, cached = _scope_cache[user_id]
if now < expires_at:
return cached
storage = await get_shared_storage()
data = await storage.get_app_password_with_scopes(user_id)
if data is None:
result = None
elif data["scopes"] is None:
result = "all"
else:
result = data["scopes"]
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
return result
+5 -656
View File
@@ -28,18 +28,13 @@ Sensitive data (tokens, secrets) is encrypted at rest using Fernet symmetric enc
import json
import logging
import os
import socket
import time
from pathlib import Path
from typing import Any, Optional
import aiosqlite
import anyio
import httpx
from anyio import to_thread
from cryptography.fernet import Fernet
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
from nextcloud_mcp_server.observability.metrics import record_db_operation
logger = logging.getLogger(__name__)
@@ -168,6 +163,10 @@ class RefreshTokenStorage:
# Run migrations in a worker thread using anyio.to_thread
# This allows Alembic to run its own async operations in a separate context
from anyio import to_thread
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
if not has_alembic:
if has_schema:
# Stamp existing database without running migrations
@@ -831,6 +830,7 @@ class RefreshTokenStorage:
resource_id: Resource identifier
auth_method: Authentication method used
"""
import socket
hostname = socket.gethostname()
timestamp = int(time.time())
@@ -1240,657 +1240,6 @@ class RefreshTokenStorage:
return deleted
# ============================================================================
# App Password Storage (multi-user BasicAuth mode)
# ============================================================================
async def store_app_password(
self,
user_id: str,
app_password: str,
) -> None:
"""
Store encrypted app password for background sync (multi-user BasicAuth mode).
Args:
user_id: Nextcloud user ID
app_password: Nextcloud app password to store
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
encrypted_password = self.cipher.encrypt(app_password.encode())
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?
)
""",
(user_id, encrypted_password, user_id, now, now),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored app password for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
# Audit log
await self._audit_log(
event="store_app_password",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
Decrypted app password, or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_password FROM app_passwords WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password = row[0]
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
logger.debug(f"Retrieved app password for user {user_id}")
return decrypted_password
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(f"Failed to decrypt app password for user {user_id}: {e}")
return None
async def delete_app_password(self, user_id: str) -> bool:
"""
Delete app password for a user.
Args:
user_id: Nextcloud user ID
Returns:
True if password was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM app_passwords WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted app password for user {user_id}")
await self._audit_log(
event="delete_app_password",
user_id=user_id,
auth_method="app_password",
)
else:
logger.debug(f"No app password to delete for user {user_id}")
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def get_all_app_password_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored app passwords.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM app_passwords ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with app passwords")
return user_ids
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
"""
Validate stored app passwords against Nextcloud and remove invalid ones.
Makes a lightweight OCS request for each stored user to check if credentials
are still valid. Removes entries that return 401/403.
Args:
nextcloud_host: Nextcloud base URL
Returns:
List of user IDs whose app passwords were removed
"""
if not self._initialized:
await self.initialize()
user_ids = await self.get_all_app_password_user_ids()
if not user_ids:
return []
removed: list[str] = []
async def _validate_user(user_id: str) -> None:
app_password = await self.get_app_password(user_id)
if not app_password:
return
try:
async with httpx.AsyncClient(
base_url=nextcloud_host,
auth=httpx.BasicAuth(user_id, app_password),
timeout=10.0,
) as client:
response = await client.get(
"/ocs/v2.php/cloud/user",
headers={
"OCS-APIRequest": "true",
"Accept": "application/json",
},
)
if response.status_code in (401, 403):
logger.info(
f"App password for {user_id} is invalid "
f"(HTTP {response.status_code}), removing"
)
await self.delete_app_password(user_id)
removed.append(user_id)
else:
logger.debug(
f"App password for {user_id} validated "
f"(HTTP {response.status_code})"
)
except Exception as e:
logger.warning(f"Could not validate app password for {user_id}: {e}")
async with anyio.create_task_group() as tg:
for user_id in user_ids:
tg.start_soon(_validate_user, user_id)
return removed
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
async def store_app_password_with_scopes(
self,
user_id: str,
app_password: str,
scopes: list[str] | None = None,
username: str | None = None,
) -> None:
"""Store encrypted app password with optional scopes and Nextcloud username.
Args:
user_id: MCP user ID (identity from OAuth token or session)
app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
# Defense-in-depth: validate scopes at storage layer
if scopes is not None:
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
ALL_SUPPORTED_SCOPES,
)
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at, scopes, username)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?,
?,
?
)
""",
(
user_id,
encrypted_password,
user_id,
now,
now,
scopes_json,
username,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(
f"Stored scoped app password for user {user_id} "
f"(scopes={'all' if scopes is None else len(scopes)}, "
f"username={username or 'N/A'})"
)
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
await self._audit_log(
event="store_app_password_with_scopes",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve app password with scopes and metadata.
Args:
user_id: MCP user ID
Returns:
Dict with keys: app_password, scopes, username, created_at, updated_at
or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_password, scopes, username, created_at, updated_at
FROM app_passwords WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password, scopes_json, username, created_at, updated_at = row
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"app_password": decrypted_password,
"scopes": scopes,
"username": username,
"created_at": created_at,
"updated_at": updated_at,
}
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
raise
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
Args:
user_id: MCP user ID
scopes: New scope list
Returns:
True if a row was updated, False if user not found
"""
if not self._initialized:
await self.initialize()
scopes_json = json.dumps(scopes)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
(scopes_json, now, user_id),
)
await db.commit()
updated = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "success")
if updated:
await self._audit_log(
event="update_app_password_scopes",
user_id=user_id,
auth_method="app_password",
)
return updated
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "error")
raise
# ── Login Flow v2: Session Tracking ──────────────────────────────────
async def store_login_flow_session(
self,
user_id: str,
poll_token: str,
poll_endpoint: str,
requested_scopes: list[str] | None = None,
expires_at: int | None = None,
) -> None:
"""Store a Login Flow v2 polling session.
Args:
user_id: MCP user ID
poll_token: Token for polling (will be encrypted)
poll_endpoint: URL to poll for completion
requested_scopes: Scopes requested in this flow
expires_at: Expiration timestamp (defaults to 20 minutes from now)
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
)
encrypted_token = self.cipher.encrypt(poll_token.encode())
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
now = int(time.time())
if expires_at is None:
expires_at = now + 1200 # 20 minutes default
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO login_flow_sessions
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
encrypted_token,
poll_endpoint,
scopes_json,
now,
expires_at,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored login flow session for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve a pending Login Flow v2 session.
Returns None if session doesn't exist or has expired.
Args:
user_id: MCP user ID
Returns:
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
or None if not found/expired
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at
FROM login_flow_sessions
WHERE user_id = ? AND expires_at > ?
""",
(user_id, now),
) as cursor:
row = await cursor.fetchone()
if not row:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
poll_token = self.cipher.decrypt(encrypted_token).decode()
requested_scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"poll_token": poll_token,
"poll_endpoint": poll_endpoint,
"requested_scopes": requested_scopes,
"created_at": created_at,
"expires_at": expires_at,
}
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(
f"Failed to retrieve login flow session for user {user_id}: {e}"
)
raise
async def delete_login_flow_session(self, user_id: str) -> bool:
"""Delete a Login Flow v2 session.
Args:
user_id: MCP user ID
Returns:
True if session was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted login flow session for user {user_id}")
await self._audit_log(
event="delete_login_flow_session",
user_id=user_id,
auth_method="login_flow",
)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def delete_expired_login_flow_sessions(self) -> int:
"""Delete all expired Login Flow v2 sessions.
Returns:
Number of sessions deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
(now,),
)
await db.commit()
count = cursor.rowcount
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if count > 0:
logger.info(f"Cleaned up {count} expired login flow sessions")
await self._audit_log(
event="delete_expired_login_flow_sessions",
user_id="system",
auth_method="login_flow",
)
return count
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
_shared_instance: RefreshTokenStorage | None = None
_shared_lock: anyio.Lock = anyio.Lock()
async def get_shared_storage() -> RefreshTokenStorage:
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
All modules that need storage should use this function instead of
creating their own lazy singletons. The lock ensures thread-safe
initialization on concurrent first-access.
"""
global _shared_instance
async with _shared_lock:
if _shared_instance is None:
_shared_instance = RefreshTokenStorage.from_env()
await _shared_instance.initialize()
return _shared_instance
async def generate_encryption_key() -> str:
"""
+34 -5
View File
@@ -25,8 +25,6 @@ import jwt
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -138,7 +136,7 @@ class TokenBrokerService:
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client."""
if self._http_client is None:
self._http_client = nextcloud_httpx_client(
self._http_client = httpx.AsyncClient(
timeout=httpx.Timeout(30.0), follow_redirects=True
)
return self._http_client
@@ -170,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.
@@ -378,7 +407,7 @@ class TokenBrokerService:
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()
@@ -448,7 +477,7 @@ class TokenBrokerService:
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()
+1 -2
View File
@@ -20,7 +20,6 @@ import httpx
import jwt
from ..config import get_settings
from ..http import nextcloud_httpx_client
from .storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
@@ -69,7 +68,7 @@ class TokenExchangeService:
self.storage: Optional[RefreshTokenStorage] = None
# Create HTTP client
self.http_client = nextcloud_httpx_client(
self.http_client = httpx.AsyncClient(
timeout=30.0,
follow_redirects=True,
)
-85
View File
@@ -1,85 +0,0 @@
"""Token utility functions for extracting user identity from MCP access tokens.
Extracted from server/oauth_tools.py to break circular import dependencies
between server/ and auth/ layers.
"""
import logging
import os
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def extract_user_id_from_token(ctx: Context) -> str:
"""Extract user_id from the MCP access token (Flow 1).
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
Args:
ctx: MCP context with access token
Returns:
user_id extracted from token, or "default_user" as fallback
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning(" ✗ No access token found via get_access_token()")
return "default_user"
token = access_token.token
is_jwt = "." in token and token.count(".") >= 2
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
# Try JWT decode first
if is_jwt:
try:
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
return user_id
except Exception as e:
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
# Opaque token - call userinfo endpoint
logger.info(" Opaque token detected, calling userinfo endpoint...")
try:
# Get userinfo endpoint from OIDC discovery
oidc_discovery_uri = os.getenv(
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
if userinfo:
user_id = userinfo.get("sub", "unknown")
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
return user_id
else:
logger.error(" ✗ Userinfo query failed")
else:
logger.error(" ✗ No userinfo_endpoint available")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
+16 -172
View File
@@ -31,8 +31,6 @@ from nextcloud_mcp_server.observability.metrics import (
record_oauth_token_validation,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -63,7 +61,7 @@ class UnifiedTokenVerifier(TokenVerifier):
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
# Common components for all modes
self.http_client = nextcloud_httpx_client(timeout=10.0)
self.http_client = httpx.AsyncClient(timeout=10.0)
# JWT verification support
self.jwks_client: PyJWKClient | None = None
@@ -119,71 +117,6 @@ class UnifiedTokenVerifier(TokenVerifier):
# Both modes do the same validation (MCP audience only)
return await self._verify_mcp_audience(token)
async def verify_token_for_management_api(self, token: str) -> AccessToken | None:
"""
Verify token for management API access (ADR-018 NC PHP app integration).
This verification accepts ANY valid Nextcloud OIDC token, not just tokens
with MCP server audience. This is needed because:
- Astrolabe (NC PHP app) uses its own OAuth client with Nextcloud OIDC
- Tokens from Astrolabe have Astrolabe's client_id as audience
- MCP server's management API should accept these tokens
Security Model:
~~~~~~~~~~~~~~~~
This relaxed audience validation is secure because:
1. **Authentication layer** (this method):
- Verifies token signature against Nextcloud's JWKS (cryptographic proof)
- Verifies token is not expired
- Extracts user identity from validated token claims
2. **Authorization layer** (management API endpoints):
- EVERY endpoint verifies: token.sub == requested_resource_owner
- Example: GET /users/{user_id}/session checks token_user_id == path_user_id
- Users can ONLY access their own resources, never another user's
3. **Attack scenario analysis**:
- Attacker with stolen token for App A cannot access user B's data
- Token's `sub` claim is cryptographically bound to a specific user
- Authorization layer rejects cross-user access attempts (403 Forbidden)
4. **Why audience validation isn't needed here**:
- Audience validation prevents token confusion attacks across services
- But management API authorization already gates access per-user
- A token valid for "astrolabe" is still bound to user X, not user Y
Args:
token: Bearer token to verify
Returns:
AccessToken if valid (regardless of audience), None otherwise
"""
# Check cache first (using separate cache key to avoid mixing with MCP tokens)
cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}"
if cache_key in self._token_cache:
userinfo, expiry = self._token_cache[cache_key]
if time.time() < expiry:
logger.debug("Management API token found in cache")
oauth_token_cache_hits_total.labels(hit="true").inc()
username = userinfo.get("sub") or userinfo.get("preferred_username")
scope_string = userinfo.get("scope", "")
scopes = scope_string.split() if scope_string else []
return AccessToken(
token=token,
client_id=userinfo.get("client_id", ""),
scopes=scopes,
expires_at=int(expiry),
resource=username,
)
else:
del self._token_cache[cache_key]
oauth_token_cache_hits_total.labels(hit="false").inc()
# Verify token without audience check
return await self._verify_without_audience_check(token, cache_key)
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
"""
Validate token has MCP audience.
@@ -253,78 +186,6 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation(validation_method, "error")
return None
async def _verify_without_audience_check(
self, token: str, cache_key: str
) -> AccessToken | None:
"""
Verify token validity without checking MCP audience or issuer.
Used for management API where tokens from Astrolabe (NC PHP app) need to
be accepted. These tokens are issued by Nextcloud OIDC to Astrolabe's
OAuth client, not MCP server's client.
What we verify:
- Token signature (cryptographic proof token is from Nextcloud OIDC)
- Token expiration (not expired)
- Token structure (valid JWT format)
What we skip:
- Audience check (token may have Astrolabe's audience, not MCP's)
- Issuer check (token may have internal Nextcloud URL as issuer)
Security guarantee:
- Authorization is enforced by management API endpoints
- Each endpoint verifies: token.sub == requested_resource_owner
- See verify_token_for_management_api() docstring for full security model
Args:
token: Bearer token to verify
cache_key: Cache key for storing validation result
Returns:
AccessToken if valid, None otherwise
"""
validation_method = "unknown"
try:
# Attempt JWT verification first
# Skip issuer check for management API tokens (may have internal URL)
if self._is_jwt_format(token) and self.jwks_client:
validation_method = "jwt"
payload = await self._verify_jwt_signature(
token, skip_issuer_check=True
)
if payload:
record_oauth_token_validation("jwt", "valid")
else:
record_oauth_token_validation("jwt", "invalid")
return None
else:
# Fall back to introspection for opaque tokens
validation_method = "introspect"
payload = await self._introspect_token(token)
if payload:
record_oauth_token_validation("introspect", "valid")
else:
record_oauth_token_validation("introspect", "invalid")
return None
# Check payload is valid
if not payload:
return None
# Skip audience validation - any valid Nextcloud token is accepted
logger.debug(
f"Management API token validated (no audience check) for user: {payload.get('sub')}"
)
# Cache and return the token
return self._create_access_token_with_cache_key(token, payload, cache_key)
except Exception as e:
logger.error(f"Management API token verification failed: {e}")
record_oauth_token_validation(validation_method, "error")
return None
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
"""
Check if token has MCP audience.
@@ -369,15 +230,12 @@ class UnifiedTokenVerifier(TokenVerifier):
"""
return "." in token and token.count(".") == 2
async def _verify_jwt_signature(
self, token: str, skip_issuer_check: bool = False
) -> dict[str, Any] | None:
async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None:
"""
Verify JWT token with signature validation using JWKS.
Args:
token: JWT token to verify
skip_issuer_check: If True, skip issuer validation (for management API tokens)
Returns:
Decoded payload if valid, None if invalid
@@ -390,22 +248,25 @@ class UnifiedTokenVerifier(TokenVerifier):
# Verify and decode JWT
# Note: We don't validate audience here - that's done separately based on mode
# Issuer validation can be skipped for management API tokens (from Astrolabe)
should_verify_issuer = (
not skip_issuer_check
and hasattr(self.settings, "oidc_issuer")
and self.settings.oidc_issuer
)
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
issuer=(
self.settings.oidc_issuer
if hasattr(self.settings, "oidc_issuer")
else None
),
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": should_verify_issuer,
"verify_iss": (
True
if hasattr(self.settings, "oidc_issuer")
and self.settings.oidc_issuer
else False
),
"verify_aud": False, # We handle audience validation separately
},
)
@@ -497,24 +358,6 @@ class UnifiedTokenVerifier(TokenVerifier):
token: The bearer token
payload: Validated token payload
Returns:
AccessToken object or None if required fields missing
"""
# Use default cache key (hash of token)
cache_key = hashlib.sha256(token.encode()).hexdigest()
return self._create_access_token_with_cache_key(token, payload, cache_key)
def _create_access_token_with_cache_key(
self, token: str, payload: dict[str, Any], cache_key: str
) -> AccessToken | None:
"""
Create AccessToken object from validated token payload with custom cache key.
Args:
token: The bearer token
payload: Validated token payload
cache_key: Key to use for caching (allows separate caches for MCP vs management API)
Returns:
AccessToken object or None if required fields missing
"""
@@ -539,13 +382,14 @@ class UnifiedTokenVerifier(TokenVerifier):
logger.warning("No 'exp' claim in token, using default TTL")
exp = int(time.time() + self.cache_ttl)
# Cache the result with the provided key
# Cache the result
token_hash = hashlib.sha256(token.encode()).hexdigest()
userinfo = {
"sub": username,
"scope": scope_string,
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
}
self._token_cache[cache_key] = (userinfo, exp)
self._token_cache[token_hash] = (userinfo, exp)
return AccessToken(
token=token,
+18 -17
View File
@@ -9,21 +9,16 @@ For OAuth mode: Requires browser-based OAuth login to establish session.
import logging
import os
import traceback
from pathlib import Path
from typing import Any
from httpx import BasicAuth
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.auth.permissions import is_nextcloud_admin
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -58,6 +53,8 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudC
if not all([nextcloud_host, username, password]):
raise RuntimeError("BasicAuth credentials not configured")
from httpx import BasicAuth
assert nextcloud_host is not None
assert username is not None
assert password is not None
@@ -108,9 +105,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
"status": str, # "syncing" or "idle"
}
"""
# Check if vector sync is enabled (supports both old and new env var names)
settings = get_settings()
if not settings.vector_sync_enabled:
# Check if vector sync is enabled
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
if not vector_sync_enabled:
return None
try:
@@ -129,10 +126,10 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
get_qdrant_client,
)
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection
@@ -260,7 +257,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
return None
try:
async with nextcloud_httpx_client(timeout=10.0) as client:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
@@ -293,7 +290,7 @@ async def _query_idp_userinfo(
User info dictionary from IdP, or None if query fails
"""
try:
async with nextcloud_httpx_client(timeout=10.0) as client:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
userinfo_uri,
headers={"Authorization": f"Bearer {access_token_str}"},
@@ -388,6 +385,8 @@ async def _get_user_info(request: Request) -> dict[str, Any]:
return user_context
except Exception as e:
import traceback
logger.error(f"Error retrieving user info: {e}")
logger.error(f"Traceback: {traceback.format_exc()}")
return {
@@ -433,6 +432,8 @@ async def user_info_html(request: Request) -> HTMLResponse:
# Check if user is admin (for Webhooks tab)
is_admin = False
try:
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
# Get authenticated Nextcloud client
nc_client = await _get_authenticated_client_for_userinfo(request)
is_admin = await is_nextcloud_admin(request, nc_client._client)
@@ -471,6 +472,8 @@ async def user_info_html(request: Request) -> HTMLResponse:
# Get Nextcloud host for generating links to apps (used by viz tab)
# Use public issuer URL if available (for browser-accessible links),
# otherwise fall back to NEXTCLOUD_HOST from settings
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
nextcloud_host_for_links = (
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
@@ -632,9 +635,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
"""
# Check if vector sync is enabled (needed for Welcome tab)
# Note: get_settings() supports both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED
settings = get_settings()
vector_sync_enabled = settings.vector_sync_enabled
vector_sync_enabled = os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
# Render template
template = _jinja_env.get_template("user_info.html")
+15 -7
View File
@@ -15,25 +15,18 @@ import logging
import time
from pathlib import Path
import anyio
import numpy as np
from jinja2 import Environment, FileSystemLoader
from qdrant_client.models import FieldCondition, Filter, MatchValue
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding.service import get_embedding_service
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
from nextcloud_mcp_server.vector.pca import PCA
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -143,6 +136,10 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
# Get authenticated HTTP client from session
# In BasicAuth mode: uses username/password from session
# In OAuth mode: uses access token from session
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
with trace_operation("vector_viz.get_auth_client"):
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
@@ -355,6 +352,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
)
else:
# Fallback: generate embedding if not available from search
from nextcloud_mcp_server.embedding.service import get_embedding_service
embedding_service = get_embedding_service()
query_embedding = await embedding_service.embed(query)
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
@@ -397,6 +396,8 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
coords = pca.fit_transform(vectors)
return coords, pca
import anyio
with trace_operation(
"vector_viz.pca_compute",
attributes={
@@ -555,6 +556,11 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
doc_id_int = int(doc_id)
# Get authenticated Nextcloud client
from nextcloud_mcp_server.auth.userinfo_routes import (
_get_authenticated_client_for_userinfo,
)
from nextcloud_mcp_server.search.context import get_chunk_with_context
# Use context expansion module to fetch chunk with surrounding context
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
chunk_context = await get_chunk_with_context(
@@ -589,6 +595,8 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
page_number = None
if doc_type == "file":
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
settings = get_settings()
qdrant_client = await get_qdrant_client()
username = request.user.display_name
+2 -4
View File
@@ -20,8 +20,6 @@ from nextcloud_mcp_server.server.webhook_presets import (
get_preset,
)
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
@@ -142,7 +140,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
assert nextcloud_host is not None # Type narrowing for type checker
assert username is not None and password is not None # Type narrowing
return nextcloud_httpx_client(
return httpx.AsyncClient(
base_url=nextcloud_host,
auth=(username, password),
timeout=30.0,
@@ -165,7 +163,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
if not nextcloud_host:
raise RuntimeError("Nextcloud host not configured")
return nextcloud_httpx_client(
return httpx.AsyncClient(
base_url=nextcloud_host,
headers={"Authorization": f"Bearer {access_token}"},
timeout=30.0,
+10 -7
View File
@@ -6,13 +6,6 @@ import uvicorn
from nextcloud_mcp_server.config import (
get_settings,
)
from nextcloud_mcp_server.migrations import (
create_migration,
downgrade_database,
get_current_revision,
show_migration_history,
upgrade_database,
)
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
from .app import get_app
@@ -296,6 +289,8 @@ def upgrade(database_path: str, revision: str):
# Use custom database path
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
"""
from nextcloud_mcp_server.migrations import upgrade_database
try:
click.echo(f"Upgrading database to revision: {revision}")
upgrade_database(database_path, revision)
@@ -340,6 +335,8 @@ def downgrade(database_path: str, revision: str):
# Downgrade to base (empty database)
$ nextcloud-mcp-server db downgrade --revision base
"""
from nextcloud_mcp_server.migrations import downgrade_database
try:
click.echo(f"Downgrading database to revision: {revision}")
downgrade_database(database_path, revision)
@@ -365,6 +362,8 @@ def current(database_path: str):
Example:
$ nextcloud-mcp-server db current
"""
from nextcloud_mcp_server.migrations import get_current_revision
try:
revision = get_current_revision(database_path)
if revision:
@@ -398,6 +397,8 @@ def history(database_path: str):
Example:
$ nextcloud-mcp-server db history
"""
from nextcloud_mcp_server.migrations import show_migration_history
try:
click.echo("Migration history:")
show_migration_history(database_path)
@@ -420,6 +421,8 @@ def migrate(message: str):
Note: You must manually edit the generated migration file to add SQL statements.
"""
from nextcloud_mcp_server.migrations import create_migration
try:
click.echo(f"Creating new migration: {message}")
create_migration(message)
+3 -3
View File
@@ -4,6 +4,7 @@ import os
from httpx import (
AsyncBaseTransport,
AsyncClient,
AsyncHTTPTransport,
Auth,
BasicAuth,
Request,
@@ -12,7 +13,6 @@ from httpx import (
)
from ..controllers.notes_search import NotesSearchController
from ..http import nextcloud_httpx_transport
from .calendar import CalendarClient
from .contacts import ContactsClient
from .cookbook import CookbookClient
@@ -67,7 +67,7 @@ class NextcloudClient:
self._client = AsyncClient(
base_url=base_url,
auth=auth,
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
event_hooks={"request": [log_request], "response": [log_response]},
timeout=Timeout(timeout=30, connect=5),
)
@@ -113,7 +113,7 @@ class NextcloudClient:
Returns:
NextcloudClient configured with bearer token authentication
"""
from ..auth import BearerAuth # noqa: PLC0415
from ..auth import BearerAuth
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
+83 -202
View File
@@ -6,16 +6,12 @@ import uuid
from typing import Any, Dict, List, Optional
import anyio
from caldav.async_collection import AsyncCalendar, AsyncEvent
from caldav.async_collection import AsyncCalendar
from caldav.async_davclient import AsyncDAVClient
from caldav.elements import cdav, dav
from httpx import Auth
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
from icalendar import Alarm, Calendar, vRecur
from icalendar import Event as ICalEvent
from icalendar import Todo as ICalTodo
from lxml import etree # type: ignore[import-untyped]
from ..config import get_nextcloud_ssl_verify
logger = logging.getLogger(__name__)
@@ -38,7 +34,6 @@ class CalendarClient:
url=f"{base_url}/remote.php/dav/",
username=username,
auth=auth,
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
)
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
@@ -105,6 +100,8 @@ class CalendarClient:
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
# Apple iCal namespace which Nextcloud doesn't recognize.
from lxml import etree # type: ignore[import-untyped]
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
@@ -258,35 +255,18 @@ class CalendarClient:
"""List events in a calendar within date range."""
calendar = self._get_calendar(calendar_name)
if start_datetime or end_datetime:
# Build CalDAV REPORT with time-range filter for server-side filtering
events = await self._search_events_by_date(
calendar, start_datetime, end_datetime
)
# Expand is only used when both bounds are provided
expanded = bool(start_datetime and end_datetime)
else:
# No date filter — fetch all events
events = await calendar.events()
expanded = False
# Get all events using caldav library (now with proper filter)
events = await calendar.events()
result = []
for event in events:
await event.load(only_if_unloaded=True)
if event.data:
if expanded:
# Server-side expansion: each response resource may contain
# multiple VEVENTs (one per recurrence occurrence)
for event_dict in self._parse_all_ical_events(event.data):
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
else:
event_dict = self._parse_ical_event(event.data)
if event_dict:
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
event_dict = self._parse_ical_event(event.data)
if event_dict:
event_dict["href"] = str(event.url)
event_dict["etag"] = ""
result.append(event_dict)
if len(result) >= limit:
break
@@ -294,53 +274,6 @@ class CalendarClient:
logger.debug(f"Found {len(result)} events")
return result
async def _search_events_by_date(
self,
calendar: AsyncCalendar,
start_datetime: Optional[dt.datetime] = None,
end_datetime: Optional[dt.datetime] = None,
) -> list:
"""Execute a CalDAV REPORT with time-range filter."""
# Ensure naive datetimes are treated as UTC
if start_datetime and start_datetime.tzinfo is None:
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
if end_datetime and end_datetime.tzinfo is None:
end_datetime = end_datetime.replace(tzinfo=dt.UTC)
# Build comp-filter with time-range (mirrors sync Calendar.build_search_xml_query)
inner_comp_filter = cdav.CompFilter(name="VEVENT")
inner_comp_filter += cdav.TimeRange(start_datetime, end_datetime)
outer_comp_filter = cdav.CompFilter(name="VCALENDAR") + inner_comp_filter
filter_element = cdav.Filter() + outer_comp_filter
# When both bounds are provided, request server-side expansion of
# recurring events (RFC 4791 §9.6.5). Each occurrence is returned as
# a separate VEVENT with its own DTSTART, with RRULE stripped.
data = cdav.CalendarData()
if start_datetime and end_datetime:
data += cdav.Expand(start_datetime, end_datetime)
query = cdav.CalendarQuery() + [dav.Prop() + data] + filter_element
body = etree.tostring(
query.xmlelement(), encoding="utf-8", xml_declaration=True
)
assert calendar.client is not None
response = await calendar.client.report(str(calendar.url), body, depth=1)
# Parse response (same pattern as AsyncCalendar.search)
objects = []
response_data = response.expand_simple_props([cdav.CalendarData()])
for href, props in response_data.items():
if href == str(calendar.url):
continue
cal_data = props.get(cdav.CalendarData.tag)
if cal_data:
obj = AsyncEvent(client=calendar.client, data=cal_data, parent=calendar)
objects.append(obj)
return objects
async def create_event(
self, calendar_name: str, event_data: Dict[str, Any]
) -> Dict[str, Any]:
@@ -650,7 +583,7 @@ class CalendarClient:
# Add categories
categories = event_data.get("categories", "")
if categories:
event.add("categories", [c.strip() for c in categories.split(",")])
event.add("categories", categories.split(","))
# Add priority and status
priority = event_data.get("priority", 5)
@@ -700,92 +633,75 @@ class CalendarClient:
cal.add_component(event)
return cal.to_ical().decode("utf-8")
def _extract_vevent_data(self, component) -> Dict[str, Any]:
"""Extract event data from a single VEVENT component.
Shared helper used by both _parse_ical_event() and _parse_all_ical_events().
"""
event_data: Dict[str, Any] = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"description": str(component.get("description", "")),
"location": str(component.get("location", "")),
"status": str(component.get("status", "CONFIRMED")),
"priority": int(component.get("priority", 5)),
"privacy": str(component.get("class", "PUBLIC")),
"url": str(component.get("url", "")),
}
# Handle dates
dtstart = component.get("dtstart")
if dtstart:
if isinstance(dtstart.dt, dt.date) and not isinstance(
dtstart.dt, dt.datetime
):
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = True
else:
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = False
dtend = component.get("dtend")
if dtend:
if isinstance(dtend.dt, dt.date) and not isinstance(dtend.dt, dt.datetime):
event_data["end_datetime"] = dtend.dt.isoformat()
else:
event_data["end_datetime"] = dtend.dt.isoformat()
# Handle categories
categories = component.get("categories")
if categories:
event_data["categories"] = self._extract_categories(categories)
# Handle recurrence
rrule = component.get("rrule")
if rrule:
event_data["recurring"] = True
event_data["recurrence_rule"] = str(rrule)
# Handle attendees
attendees = []
for attendee in component.get("attendee", []):
if isinstance(attendee, list):
attendees.extend(str(a).replace("mailto:", "") for a in attendee)
else:
attendees.append(str(attendee).replace("mailto:", ""))
if attendees:
event_data["attendees"] = ",".join(attendees)
return event_data
def _parse_ical_event(self, ical_text: str) -> Optional[Dict[str, Any]]:
"""Parse iCalendar text and extract the first event."""
"""Parse iCalendar text and extract event data."""
try:
cal = Calendar.from_ical(ical_text)
for component in cal.walk():
if component.name == "VEVENT":
return self._extract_vevent_data(component)
event_data = {
"uid": str(component.get("uid", "")),
"title": str(component.get("summary", "")),
"description": str(component.get("description", "")),
"location": str(component.get("location", "")),
"status": str(component.get("status", "CONFIRMED")),
"priority": int(component.get("priority", 5)),
"privacy": str(component.get("class", "PUBLIC")),
"url": str(component.get("url", "")),
}
# Handle dates
dtstart = component.get("dtstart")
if dtstart:
if isinstance(dtstart.dt, dt.date) and not isinstance(
dtstart.dt, dt.datetime
):
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = True
else:
event_data["start_datetime"] = dtstart.dt.isoformat()
event_data["all_day"] = False
dtend = component.get("dtend")
if dtend:
if isinstance(dtend.dt, dt.date) and not isinstance(
dtend.dt, dt.datetime
):
event_data["end_datetime"] = dtend.dt.isoformat()
else:
event_data["end_datetime"] = dtend.dt.isoformat()
# Handle categories
categories = component.get("categories")
if categories:
event_data["categories"] = self._extract_categories(categories)
# Handle recurrence
rrule = component.get("rrule")
if rrule:
event_data["recurring"] = True
event_data["recurrence_rule"] = str(rrule)
# Handle attendees
attendees = []
for attendee in component.get("attendee", []):
if isinstance(attendee, list):
attendees.extend(
str(a).replace("mailto:", "") for a in attendee
)
else:
attendees.append(str(attendee).replace("mailto:", ""))
if attendees:
event_data["attendees"] = ",".join(attendees)
return event_data
return None
except Exception as e:
logger.error(f"Error parsing iCalendar event: {e}")
return None
def _parse_all_ical_events(self, ical_text: str) -> list[Dict[str, Any]]:
"""Parse iCalendar text and extract ALL event occurrences.
Used with server-side expansion where a single VCALENDAR contains
multiple VEVENT components (one per recurrence occurrence).
"""
results: list[Dict[str, Any]] = []
try:
cal = Calendar.from_ical(ical_text)
for component in cal.walk():
if component.name == "VEVENT":
results.append(self._extract_vevent_data(component))
except Exception as e:
logger.error(f"Error parsing iCalendar events: {e}")
return results
def _merge_ical_properties(
self, raw_ical: str, event_data: Dict[str, Any], event_uid: str
) -> str:
@@ -811,50 +727,6 @@ class CalendarClient:
if "url" in event_data:
component["URL"] = event_data["url"]
# Handle categories
if "categories" in event_data:
categories_str = event_data["categories"]
if categories_str:
component["CATEGORIES"] = [
c.strip() for c in categories_str.split(",")
]
elif "CATEGORIES" in component:
del component["CATEGORIES"]
# Handle recurrence rule
if "recurrence_rule" in event_data:
rrule_str = event_data["recurrence_rule"]
if rrule_str:
component["RRULE"] = vRecur.from_ical(rrule_str)
elif "RRULE" in component:
del component["RRULE"]
# Handle attendees
if "attendees" in event_data:
attendees_str = event_data["attendees"]
# Remove all existing attendees first
while "ATTENDEE" in component:
del component["ATTENDEE"]
if attendees_str:
for email in attendees_str.split(","):
if email.strip():
component.add("attendee", f"mailto:{email.strip()}")
# Handle reminder (VALARM)
if "reminder_minutes" in event_data:
component.subcomponents = [
sub
for sub in component.subcomponents
if sub.name != "VALARM"
]
minutes = event_data["reminder_minutes"]
if minutes > 0:
alarm = Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", "Event reminder")
alarm.add("trigger", dt.timedelta(minutes=-minutes))
component.add_component(alarm)
# Handle dates
if "start_datetime" in event_data:
start_str = event_data["start_datetime"]
@@ -885,6 +757,8 @@ class CalendarClient:
component["DTEND"] = end_dt
# Update timestamps
from icalendar import vDDDTypes
now = dt.datetime.now(dt.UTC)
component["LAST-MODIFIED"] = vDDDTypes(now)
component["DTSTAMP"] = vDDDTypes(now)
@@ -949,18 +823,24 @@ class CalendarClient:
# Due date
due = todo_data.get("due", "")
if due:
from icalendar import vDDDTypes
due_dt = self._ensure_timezone_aware(due)
todo.add("due", vDDDTypes(due_dt))
# Start date
dtstart = todo_data.get("dtstart", "")
if dtstart:
from icalendar import vDDDTypes
start_dt = self._ensure_timezone_aware(dtstart)
todo.add("dtstart", vDDDTypes(start_dt))
# Completed timestamp
completed = todo_data.get("completed", "")
if completed:
from icalendar import vDDDTypes
completed_dt = self._ensure_timezone_aware(completed)
todo.add("completed", vDDDTypes(completed_dt))
@@ -1049,6 +929,9 @@ class CalendarClient:
component["PERCENT-COMPLETE"] = percent_value
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
# Import vDDDTypes at the beginning for datetime formatting
from icalendar import vDDDTypes
# Handle due date
if "due" in todo_data:
due_str = todo_data["due"]
@@ -1077,9 +960,7 @@ class CalendarClient:
if "categories" in todo_data:
categories_str = todo_data["categories"]
if categories_str:
component["CATEGORIES"] = [
c.strip() for c in categories_str.split(",")
]
component["CATEGORIES"] = categories_str.split(",")
logger.debug(f"Set CATEGORIES to {categories_str}")
# Update timestamps
-1
View File
@@ -274,7 +274,6 @@ class ContactsClient(BaseNextcloudClient):
"nickname": contact.nickname,
"birthday": contact.bday,
"email": contact.email,
"tel": contact.tel,
},
"addressdata": addressdata,
}
+9 -1
View File
@@ -2,7 +2,6 @@
import logging
from typing import Any, Dict, List
from urllib.parse import quote
from httpx import Timeout
@@ -165,6 +164,9 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of matching recipe stubs
"""
# URL encode the query
from urllib.parse import quote
encoded_query = quote(query)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
@@ -191,6 +193,8 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of recipe stubs in the category
"""
from urllib.parse import quote
encoded_category = quote(category)
response = await self._make_request(
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
@@ -207,6 +211,8 @@ class CookbookClient(BaseNextcloudClient):
Returns:
New category name
"""
from urllib.parse import quote
encoded_old_name = quote(old_name)
response = await self._make_request(
"PUT",
@@ -235,6 +241,8 @@ class CookbookClient(BaseNextcloudClient):
Returns:
List of recipe stubs matching the keywords
"""
from urllib.parse import quote
# Join keywords with commas
keywords_str = ",".join(keywords)
encoded_keywords = quote(keywords_str)
+21 -22
View File
@@ -285,23 +285,28 @@ class DeckClient(BaseNextcloudClient):
archived: Optional[bool] = None,
done: Optional[str] = None,
) -> None:
# Deck PUT API is a full replacement - all required fields must be sent.
# Fetch current card to preserve values for fields not being updated.
# First, get the current card to use existing values for required fields
current_card = await self.get_card(board_id, stack_id, card_id)
# Build payload with required fields always included
json_data = {
# Title is required by the API
"title": title if title is not None else current_card.title,
# Type is required by the API
"type": type if type is not None else current_card.type,
# Owner is required by the API (model validator ensures it's a string)
"owner": owner if owner is not None else current_card.owner,
# Description must be sent to preserve it (PUT clears omitted fields)
"description": description
if description is not None
else (current_card.description or ""),
}
json_data = {}
if title is not None:
json_data["title"] = title
if description is not None:
json_data["description"] = description
# Type is required by the API, use provided or keep current
json_data["type"] = type if type is not None else current_card.type
# Owner is required by the API, use provided or keep current
json_data["owner"] = (
owner
if owner is not None
else (
current_card.owner
if isinstance(current_card.owner, str)
else current_card.owner.uid
if hasattr(current_card.owner, "uid")
else current_card.owner.primaryKey
)
)
if order is not None:
json_data["order"] = order
if duedate is not None:
@@ -386,17 +391,11 @@ class DeckClient(BaseNextcloudClient):
order: int,
target_stack_id: int,
) -> None:
# Use the non-API route /cards/{cardId}/reorder which correctly reads
# stackId from the body. The API route /api/.../stacks/{stackId}/cards/...
# has a parameter conflict where URL stackId overrides body stackId.
# See: https://github.com/cbcoutinho/nextcloud-mcp-server/issues/469
json_data = {"order": order, "stackId": target_stack_id}
headers = self._get_deck_headers()
await self._make_request(
"PUT",
f"/apps/deck/cards/{card_id}/reorder",
f"/apps/deck/api/v1.0/boards/{board_id}/stacks/{stack_id}/cards/{card_id}/reorder",
json=json_data,
headers=headers,
)
# Labels
+5 -1
View File
@@ -4,7 +4,6 @@ import logging
from typing import Any, AsyncIterator, Dict, Optional
from .base import BaseNextcloudClient
from .webdav import WebDAVClient
logger = logging.getLogger(__name__)
@@ -158,6 +157,9 @@ class NotesClient(BaseNextcloudClient):
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
)
try:
# Import here to avoid circular imports
from .webdav import WebDAVClient
webdav_client = WebDAVClient(self._client, self.username)
await webdav_client.cleanup_old_attachment_directory(
note_id=note_id, old_category=old_note.get("category", "")
@@ -202,6 +204,8 @@ class NotesClient(BaseNextcloudClient):
# Clean up attachment directories
try:
from .webdav import WebDAVClient
webdav_client = WebDAVClient(self._client, self.username)
for cat in potential_categories:
+4 -2
View File
@@ -3,9 +3,7 @@
import logging
import mimetypes
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import unquote
from httpx import HTTPStatusError
@@ -1261,6 +1259,8 @@ class WebDAVClient(BaseNextcloudClient):
continue
# Decode href path and extract the file path
from urllib.parse import unquote
href_path = unquote(href_elem.text)
# Remove WebDAV prefix to get user-relative path
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
@@ -1269,6 +1269,8 @@ class WebDAVClient(BaseNextcloudClient):
# Parse last modified timestamp
last_modified_timestamp = None
if lastmodified_elem is not None and lastmodified_elem.text:
from email.utils import parsedate_to_datetime
try:
dt = parsedate_to_datetime(lastmodified_elem.text)
last_modified_timestamp = int(dt.timestamp())
+29 -219
View File
@@ -1,11 +1,9 @@
import logging
import logging.config
import os
import socket
import ssl
from dataclasses import dataclass
from enum import Enum
from typing import Any
from typing import Any, Optional
class DeploymentMode(Enum):
@@ -165,49 +163,30 @@ def get_document_processor_config() -> dict[str, Any]:
class Settings:
"""Application settings from environment variables."""
# Deployment mode (ADR-021: explicit mode selection)
# Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
deployment_mode: str | None = None
# OAuth/OIDC settings
oidc_discovery_url: str | None = None
oidc_client_id: str | None = None
oidc_client_secret: str | None = None
oidc_issuer: str | None = None
oidc_discovery_url: Optional[str] = None
oidc_client_id: Optional[str] = None
oidc_client_secret: Optional[str] = None
oidc_issuer: Optional[str] = None
# Nextcloud settings
nextcloud_host: str | None = None
nextcloud_username: str | None = None
nextcloud_password: str | None = None
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: str | None = None
nextcloud_host: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
# ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
# Token verification endpoints
jwks_uri: str | None = None
introspection_uri: str | None = None
userinfo_uri: str | None = None
jwks_uri: Optional[str] = None
introspection_uri: Optional[str] = None
userinfo_uri: Optional[str] = None
# Progressive Consent settings (always enabled - no flag needed)
enable_token_exchange: bool = False
enable_offline_access: bool = False
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
# When enabled, MCP server extracts BasicAuth credentials from request headers
# and passes them through to Nextcloud APIs (no storage, stateless)
enable_multi_user_basic_auth: bool = False
# Login Flow v2 settings (ADR-022)
enable_login_flow: bool = False
# Token exchange cache settings
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
@@ -218,8 +197,8 @@ class Settings:
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
# Used for webhook tracking (all modes) and OAuth token storage.
# Defaults to /tmp/tokens.db
token_encryption_key: str | None = None
token_storage_db: str | None = None
token_encryption_key: Optional[str] = None
token_storage_db: Optional[str] = None
# Vector sync settings (ADR-007)
vector_sync_enabled: bool = False
@@ -229,19 +208,19 @@ class Settings:
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
# Qdrant settings (mutually exclusive modes)
qdrant_url: str | None = None # Network mode: http://qdrant:6333
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
qdrant_api_key: str | None = None
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
qdrant_api_key: Optional[str] = None
qdrant_collection: str = "nextcloud_content"
# Ollama settings (for embeddings)
ollama_base_url: str | None = None
ollama_base_url: Optional[str] = None
ollama_embedding_model: str = "nomic-embed-text"
ollama_verify_ssl: bool = True
# OpenAI settings (for embeddings)
openai_api_key: str | None = None
openai_base_url: str | None = None
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)
@@ -251,7 +230,7 @@ class Settings:
# Observability settings
metrics_enabled: bool = True
metrics_port: int = 9090
otel_exporter_otlp_endpoint: str | None = None
otel_exporter_otlp_endpoint: Optional[str] = None
otel_exporter_verify_ssl: bool = False
otel_service_name: str = "nextcloud-mcp-server"
otel_traces_sampler: str = "always_on"
@@ -261,23 +240,9 @@ class Settings:
log_include_trace_context: bool = True
def __post_init__(self):
"""Validate configuration and set defaults."""
"""Validate Qdrant configuration and set defaults."""
logger = logging.getLogger(__name__)
# Validate SSL/TLS configuration
if not self.nextcloud_verify_ssl:
logger.warning(
"NEXTCLOUD_VERIFY_SSL is disabled. "
"TLS certificate verification is turned off for all Nextcloud connections. "
"This is insecure and should only be used for development/testing."
)
if self.nextcloud_ca_bundle:
if not os.path.isfile(self.nextcloud_ca_bundle):
raise ValueError(
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
)
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
# Ensure mutual exclusivity
if self.qdrant_url and self.qdrant_location:
raise ValueError(
@@ -361,6 +326,7 @@ class Settings:
Returns:
Collection name string
"""
import socket
# Use explicit override if user configured non-default value
if self.qdrant_collection != "nextcloud_content":
@@ -379,131 +345,6 @@ class Settings:
return f"{deployment_id}-{model_name}"
# ADR-021: Property aliases for new naming convention
# These provide the new names while maintaining backward compatibility with old field names
@property
def enable_semantic_search(self) -> bool:
"""Semantic search enabled (ADR-021 alias for vector_sync_enabled)."""
return self.vector_sync_enabled
@property
def enable_background_operations(self) -> bool:
"""Background operations enabled (ADR-021 alias for enable_offline_access)."""
return self.enable_offline_access
def _get_semantic_search_enabled() -> bool:
"""Get semantic search enabled status, supporting both old and new variable names.
Supports:
- ENABLE_SEMANTIC_SEARCH (new, preferred)
- VECTOR_SYNC_ENABLED (old, deprecated)
Returns:
True if semantic search should be enabled
"""
logger = logging.getLogger(__name__)
new_value = os.getenv("ENABLE_SEMANTIC_SEARCH", "").lower() == "true"
old_value = os.getenv("VECTOR_SYNC_ENABLED", "").lower() == "true"
if new_value and old_value:
logger.warning(
"Both ENABLE_SEMANTIC_SEARCH and VECTOR_SYNC_ENABLED are set. "
"Using ENABLE_SEMANTIC_SEARCH. "
"VECTOR_SYNC_ENABLED is deprecated and will be removed in v1.0.0."
)
elif old_value and not new_value:
logger.warning(
"VECTOR_SYNC_ENABLED is deprecated. "
"Please use ENABLE_SEMANTIC_SEARCH instead. "
"Support for VECTOR_SYNC_ENABLED will be removed in v1.0.0."
)
return new_value or old_value
def _is_multi_user_mode() -> bool:
"""Detect if this is a multi-user deployment mode.
Multi-user modes are:
- Multi-user BasicAuth (ENABLE_MULTI_USER_BASIC_AUTH=true)
- OAuth Single-Audience (no username/password set)
- OAuth Token Exchange (ENABLE_TOKEN_EXCHANGE=true)
Single-user modes are:
- Single-user BasicAuth (username and password both set)
- Smithery Stateless (SMITHERY_DEPLOYMENT=true)
Returns:
True if multi-user mode detected
"""
# Smithery is always single-user (stateless)
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
return False
# Multi-user BasicAuth explicitly enabled
if os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true":
return True
# Token exchange implies OAuth multi-user
if os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true":
return True
# If both username and password are set, it's single-user BasicAuth
has_username = bool(os.getenv("NEXTCLOUD_USERNAME"))
has_password = bool(os.getenv("NEXTCLOUD_PASSWORD"))
if has_username and has_password:
return False
# Otherwise, assume OAuth multi-user (default when no credentials provided)
return True
def _get_background_operations_enabled() -> bool:
"""Get background operations enabled status with auto-enablement for semantic search.
Supports:
- ENABLE_BACKGROUND_OPERATIONS (new, preferred)
- ENABLE_OFFLINE_ACCESS (old, deprecated)
- Auto-enabled if ENABLE_SEMANTIC_SEARCH=true in multi-user modes
Returns:
True if background operations should be enabled
"""
logger = logging.getLogger(__name__)
# Check new and old variable names
explicit = os.getenv("ENABLE_BACKGROUND_OPERATIONS", "").lower() == "true"
legacy = os.getenv("ENABLE_OFFLINE_ACCESS", "").lower() == "true"
if explicit and legacy:
logger.warning(
"Both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS are set. "
"Using ENABLE_BACKGROUND_OPERATIONS. "
"ENABLE_OFFLINE_ACCESS is deprecated and will be removed in v1.0.0."
)
elif legacy and not explicit:
logger.warning(
"ENABLE_OFFLINE_ACCESS is deprecated. "
"Please use ENABLE_BACKGROUND_OPERATIONS instead. "
"Support for ENABLE_OFFLINE_ACCESS will be removed in v1.0.0."
)
# Auto-enable if semantic search is enabled in multi-user mode
semantic_search_enabled = _get_semantic_search_enabled()
is_multi_user = _is_multi_user_mode()
auto_enabled = semantic_search_enabled and is_multi_user
if auto_enabled and not (explicit or legacy):
logger.info(
"Automatically enabled background operations for semantic search in multi-user mode. "
"Set ENABLE_BACKGROUND_OPERATIONS=false to disable (this will also disable semantic search)."
)
return explicit or legacy or auto_enabled
def get_settings() -> Settings:
"""Get application settings from environment variables.
@@ -511,13 +352,7 @@ def get_settings() -> Settings:
Returns:
Settings object with configuration values
"""
# Get consolidated values with smart dependency resolution
enable_semantic_search = _get_semantic_search_enabled()
enable_background_operations = _get_background_operations_enabled()
return Settings(
# Deployment mode (ADR-021)
deployment_mode=os.getenv("MCP_DEPLOYMENT_MODE"),
# OAuth/OIDC settings
oidc_discovery_url=os.getenv("OIDC_DISCOVERY_URL"),
oidc_client_id=os.getenv("NEXTCLOUD_OIDC_CLIENT_ID"),
@@ -527,12 +362,6 @@ def get_settings() -> Settings:
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl=(
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
),
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
# ADR-005: Token Audience Validation
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
@@ -544,20 +373,18 @@ def get_settings() -> Settings:
enable_token_exchange=(
os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true"
),
enable_offline_access=enable_background_operations, # Smart dependency resolution
# Multi-user BasicAuth pass-through mode
enable_multi_user_basic_auth=(
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
enable_offline_access=(
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
),
# Login Flow v2 settings (ADR-022)
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
# Token exchange cache settings
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"),
token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"),
# Vector sync settings (ADR-007)
vector_sync_enabled=enable_semantic_search, # Smart dependency resolution
vector_sync_enabled=(
os.getenv("VECTOR_SYNC_ENABLED", "false").lower() == "true"
),
vector_sync_scan_interval=int(os.getenv("VECTOR_SYNC_SCAN_INTERVAL", "300")),
vector_sync_processor_workers=int(
os.getenv("VECTOR_SYNC_PROCESSOR_WORKERS", "3")
@@ -600,20 +427,3 @@ def get_settings() -> Settings:
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
== "true",
)
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
"""Return the SSL verification setting for Nextcloud connections.
Returns:
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
- True otherwise (default system CA verification)
"""
settings = get_settings()
if not settings.nextcloud_verify_ssl:
return False
if settings.nextcloud_ca_bundle:
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
return ctx
return True
-459
View File
@@ -1,459 +0,0 @@
"""Configuration validation and mode detection for the MCP server.
This module provides:
- Mode detection based on configuration
- Configuration validation with clear error messages
- Single source of truth for deployment mode requirements
See ADR-020 for detailed architecture and deployment mode documentation.
"""
import logging
import os
from dataclasses import dataclass
from enum import Enum
from nextcloud_mcp_server.config import Settings
logger = logging.getLogger(__name__)
class AuthMode(Enum):
"""Authentication mode for the MCP server.
Determines how users authenticate and how the server accesses Nextcloud.
"""
SINGLE_USER_BASIC = "single_user_basic"
MULTI_USER_BASIC = "multi_user_basic"
OAUTH_SINGLE_AUDIENCE = "oauth_single"
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
SMITHERY_STATELESS = "smithery"
@dataclass
class ModeRequirements:
"""Requirements for a deployment mode.
Attributes:
required: Configuration variables that must be set
optional: Configuration variables that may be set
forbidden: Configuration variables that should not be set
conditional: Additional requirements based on feature flags
Format: {feature_flag: [required_vars]}
description: Human-readable description of the mode
"""
required: list[str]
optional: list[str]
forbidden: list[str]
conditional: dict[str, list[str]]
description: str
# Mode requirements definition
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
AuthMode.SINGLE_USER_BASIC: ModeRequirements(
required=["nextcloud_host", "nextcloud_username", "nextcloud_password"],
optional=[
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
"document_chunk_size",
"document_chunk_overlap",
],
forbidden=[
"enable_multi_user_basic_auth",
"enable_token_exchange",
"oidc_client_id",
"oidc_client_secret",
],
conditional={
"vector_sync_enabled": [
# Either qdrant_url OR qdrant_location (checked in Settings.__post_init__)
# At least one embedding provider (ollama_base_url OR openai_api_key)
],
},
description="Single-user deployment with BasicAuth credentials. "
"Suitable for personal Nextcloud instances and local development.",
),
AuthMode.MULTI_USER_BASIC: ModeRequirements(
required=["nextcloud_host", "enable_multi_user_basic_auth"],
optional=[
# Background sync with app passwords (via Astrolabe)
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
"oidc_client_id",
"oidc_client_secret",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
],
conditional={
"enable_offline_access": [
# OAuth credentials validated separately (lines 397-406) with clearer error message
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="Multi-user deployment with BasicAuth pass-through. "
"Users provide credentials in request headers. "
"Optional background sync using app passwords stored via Astrolabe.",
),
AuthMode.OAUTH_SINGLE_AUDIENCE: ModeRequirements(
required=["nextcloud_host"],
optional=[
# OAuth credentials (uses DCR if not provided)
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
# Scopes
"nextcloud_oidc_scopes",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_token_exchange",
"enable_multi_user_basic_auth",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment with single-audience tokens. "
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
"Uses Dynamic Client Registration if credentials not provided.",
),
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
required=["nextcloud_host", "enable_token_exchange"],
optional=[
# OAuth credentials
"oidc_client_id",
"oidc_client_secret",
"oidc_discovery_url",
# Token exchange settings
"token_exchange_cache_ttl",
# Offline access
"enable_offline_access",
"token_encryption_key",
"token_storage_db",
# Vector sync
"vector_sync_enabled",
"qdrant_url",
"qdrant_location",
"ollama_base_url",
"ollama_embedding_model",
"openai_api_key",
"openai_embedding_model",
],
forbidden=[
"nextcloud_username",
"nextcloud_password",
"enable_multi_user_basic_auth",
],
conditional={
"enable_offline_access": [
"token_encryption_key",
"token_storage_db",
],
# Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically
# enables background operations in multi-user modes. No explicit
# enable_offline_access setting required.
},
description="OAuth multi-user deployment with token exchange (RFC 8693). "
"MCP tokens are separate from Nextcloud tokens. "
"Server exchanges MCP token for Nextcloud token on each request.",
),
AuthMode.SMITHERY_STATELESS: ModeRequirements(
required=[], # All config from session URL params
optional=[],
forbidden=[
"nextcloud_host",
"nextcloud_username",
"nextcloud_password",
"enable_multi_user_basic_auth",
"enable_token_exchange",
"enable_offline_access",
"vector_sync_enabled",
"oidc_client_id",
"oidc_client_secret",
],
conditional={},
description="Stateless multi-tenant deployment for Smithery platform. "
"Configuration comes from session URL parameters. "
"No persistent storage, no OAuth, no vector sync.",
),
}
def detect_auth_mode(settings: Settings) -> AuthMode:
"""Detect authentication mode from configuration.
Mode detection priority (ADR-021):
0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021
1. Smithery (explicit flag)
2. Token exchange (most specific OAuth mode)
3. Multi-user BasicAuth
4. Single-user BasicAuth
5. OAuth single-audience (default OAuth mode)
Args:
settings: Application settings
Returns:
Detected AuthMode
Raises:
ValueError: If explicit deployment_mode is invalid or conflicts with detected mode
"""
logger = logging.getLogger(__name__)
# ADR-021: Check for explicit deployment mode first
if settings.deployment_mode:
mode_str = settings.deployment_mode.lower().strip()
# Map string to AuthMode enum
mode_map = {
"single_user_basic": AuthMode.SINGLE_USER_BASIC,
"multi_user_basic": AuthMode.MULTI_USER_BASIC,
"oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE,
"oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE,
"smithery": AuthMode.SMITHERY_STATELESS,
}
if mode_str not in mode_map:
valid_modes = ", ".join(mode_map.keys())
raise ValueError(
f"Invalid MCP_DEPLOYMENT_MODE: '{settings.deployment_mode}'. "
f"Valid values: {valid_modes}"
)
explicit_mode = mode_map[mode_str]
logger.info(f"Using explicit deployment mode: {explicit_mode.value}")
return explicit_mode
# Auto-detection (existing behavior)
# Check for Smithery mode (explicit environment variable)
# Note: This checks the environment directly, not settings
# because Smithery mode has no settings-based config
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
return AuthMode.SMITHERY_STATELESS
# Check for token exchange (most specific OAuth mode)
if settings.enable_token_exchange:
return AuthMode.OAUTH_TOKEN_EXCHANGE
# Check for multi-user BasicAuth
if settings.enable_multi_user_basic_auth:
return AuthMode.MULTI_USER_BASIC
# Check for single-user BasicAuth (explicit credentials)
if settings.nextcloud_username and settings.nextcloud_password:
return AuthMode.SINGLE_USER_BASIC
# Default: OAuth single-audience mode
# This is the safest multi-user mode (no credential storage)
return AuthMode.OAUTH_SINGLE_AUDIENCE
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
"""Validate configuration for detected mode.
Args:
settings: Application settings
Returns:
Tuple of (detected_mode, list_of_errors)
Empty list means valid configuration.
"""
mode = detect_auth_mode(settings)
requirements = MODE_REQUIREMENTS[mode]
errors: list[str] = []
logger.debug(f"Validating configuration for mode: {mode.value}")
# Check required variables
for var in requirements.required:
value = getattr(settings, var, None)
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] Missing required configuration: {var.upper()}"
)
# Check forbidden variables
for var in requirements.forbidden:
value = getattr(settings, var, None)
# For bools, check if True (forbidden means must be False/unset)
# For strings, check if non-empty
is_set = False
if isinstance(value, bool):
is_set = value is True
elif isinstance(value, str):
is_set = bool(value.strip())
elif value is not None:
is_set = True
if is_set:
errors.append(
f"[{mode.value}] Forbidden configuration: {var.upper()} "
f"should not be set in this mode"
)
# Check conditional requirements
for condition, required_vars in requirements.conditional.items():
# Check if the condition is enabled
condition_value = getattr(settings, condition, None)
is_enabled = False
if isinstance(condition_value, bool):
is_enabled = condition_value is True
elif isinstance(condition_value, str):
is_enabled = bool(condition_value.strip())
elif condition_value is not None:
is_enabled = True
if is_enabled:
# Check that all required vars for this condition are set
for var in required_vars:
value = getattr(settings, var, None)
# For boolean requirements, check that they are True (not just set)
if hasattr(Settings, var):
field_type = type(getattr(Settings(), var, None))
if field_type is bool:
if value is not True:
errors.append(
f"[{mode.value}] {var.upper()} must be enabled when "
f"{condition.upper()} is enabled"
)
continue
# For non-boolean requirements, check that they are set
if value is None or (isinstance(value, str) and not value.strip()):
errors.append(
f"[{mode.value}] {var.upper()} is required when "
f"{condition.upper()} is enabled"
)
# Special validations for specific modes
if mode == AuthMode.SINGLE_USER_BASIC:
# Validate that NEXTCLOUD_HOST doesn't have trailing slash
if settings.nextcloud_host and settings.nextcloud_host.endswith("/"):
errors.append(
f"[{mode.value}] NEXTCLOUD_HOST should not have trailing slash: "
f"{settings.nextcloud_host}"
)
if mode in [
AuthMode.OAUTH_SINGLE_AUDIENCE,
AuthMode.OAUTH_TOKEN_EXCHANGE,
]:
# If OAuth credentials not provided, DCR must be available
# (This is a runtime check, not a config check, so we just warn)
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup."
)
if mode == AuthMode.MULTI_USER_BASIC:
# If background operations enabled, check for OAuth credentials (for app password retrieval)
# Allow DCR as fallback, just like OAuth modes
if settings.enable_offline_access:
if not settings.oidc_client_id or not settings.oidc_client_secret:
logger.info(
f"[{mode.value}] OAuth credentials not configured. "
"Will attempt Dynamic Client Registration (DCR) at startup "
"(required for app password retrieval via Astrolabe)."
)
# Note: Vector sync no longer requires explicit ENABLE_OFFLINE_ACCESS setting
# ENABLE_SEMANTIC_SEARCH (formerly VECTOR_SYNC_ENABLED) automatically enables
# background operations in multi-user modes via smart dependency resolution
# in config.py
# Note: Embedding provider validation removed - Simple provider is always
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
# for better quality embeddings.
return mode, errors
def get_mode_summary(mode: AuthMode) -> str:
"""Get human-readable summary of a deployment mode.
Args:
mode: Deployment mode
Returns:
Multi-line string describing the mode
"""
requirements = MODE_REQUIREMENTS[mode]
summary_lines = [
f"Mode: {mode.value}",
f"Description: {requirements.description}",
"",
"Required configuration:",
]
if requirements.required:
for var in requirements.required:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none - configured via session)")
summary_lines.append("")
summary_lines.append("Optional configuration:")
if requirements.optional:
for var in requirements.optional:
summary_lines.append(f" - {var.upper()}")
else:
summary_lines.append(" (none)")
if requirements.conditional:
summary_lines.append("")
summary_lines.append("Conditional requirements:")
for condition, vars in requirements.conditional.items():
summary_lines.append(f" When {condition.upper()} is enabled:")
for var in vars:
summary_lines.append(f" - {var.upper()}")
return "\n".join(summary_lines)
+6 -129
View File
@@ -5,12 +5,6 @@ import logging
from httpx import BasicAuth
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
@@ -73,24 +67,19 @@ async def get_client(ctx: Context) -> NextcloudClient:
return _get_client_from_session_config(ctx)
settings = get_settings()
# Multi-user BasicAuth pass-through mode - extract credentials from request
if settings.enable_multi_user_basic_auth:
return _get_client_from_basic_auth(ctx)
lifespan_ctx = ctx.request_context.lifespan_context
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
# OAuth token is only used for MCP session identity, not NC API calls
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
# BasicAuth mode - use shared client (no token exchange)
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
# OAuth mode (has 'nextcloud_host' attribute)
if hasattr(lifespan_ctx, "nextcloud_host"):
from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
if settings.enable_token_exchange:
# Mode 2: Exchange MCP token for Nextcloud token
# Token was validated to have MCP audience in UnifiedTokenVerifier
@@ -137,7 +126,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
ValueError: If required session config fields are missing
"""
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
from nextcloud_mcp_server.app import get_smithery_session_config
session_config = get_smithery_session_config()
@@ -188,115 +177,3 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
username=username,
auth=BasicAuth(username, app_password),
)
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
"""
Create NextcloudClient from BasicAuth credentials in request headers.
For multi-user BasicAuth pass-through mode, this function extracts
username/password from the Authorization: Basic header (stored by
BasicAuthMiddleware) and creates a client that passes these credentials
through to Nextcloud APIs.
The credentials are NOT stored persistently - they exist only for the
duration of this request (stateless).
Args:
ctx: MCP request context with basic_auth in request state
Returns:
NextcloudClient configured with BasicAuth credentials
Raises:
ValueError: If BasicAuth credentials not found in request or if
NEXTCLOUD_HOST is not configured
"""
settings = get_settings()
# Validate that NEXTCLOUD_HOST is configured
if not settings.nextcloud_host:
raise ValueError(
"NEXTCLOUD_HOST environment variable must be set for multi-user BasicAuth mode"
)
# Extract BasicAuth credentials from request state (set by BasicAuthMiddleware)
# Access scope through the request object
scope = getattr(ctx.request_context.request, "scope", None)
if scope is None:
raise ValueError("Request scope not available in context")
request_state = scope.get("state", {})
basic_auth = request_state.get("basic_auth")
if not basic_auth:
raise ValueError(
"BasicAuth credentials not found in request. "
"Ensure Authorization: Basic header is provided with valid credentials."
)
username = basic_auth.get("username")
password = basic_auth.get("password")
if not username or not password:
raise ValueError("Invalid BasicAuth credentials - missing username or password")
logger.debug(
f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}"
)
# Create client that passes BasicAuth credentials through to Nextcloud
# settings.nextcloud_host is guaranteed to be str after the check above
return NextcloudClient(
base_url=settings.nextcloud_host,
username=username,
auth=BasicAuth(username, password),
)
async def _get_client_from_login_flow(
ctx: Context, nextcloud_host: str
) -> NextcloudClient:
"""Create NextcloudClient from stored Login Flow v2 app password.
In Login Flow v2 mode, the OAuth token only provides MCP session identity.
Nextcloud API calls always use the stored app password obtained via Login Flow v2.
Args:
ctx: MCP context (used to extract user identity)
nextcloud_host: Nextcloud instance URL
Returns:
NextcloudClient with stored app password credentials
Raises:
ProvisioningRequiredError: If no stored app password exists
"""
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
user_id = await extract_user_id_from_token(ctx)
if not user_id or user_id == "default_user":
raise ProvisioningRequiredError(
"Cannot determine user identity from MCP token."
)
storage = await get_shared_storage()
app_data = await storage.get_app_password_with_scopes(user_id)
if not app_data:
raise ProvisioningRequiredError(
"Nextcloud access not provisioned. "
"Call nc_auth_provision_access to complete Login Flow."
)
username = app_data.get("username") or user_id
logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}")
return NextcloudClient(
base_url=nextcloud_host,
username=username,
auth=BasicAuth(username, app_data["app_password"]),
)
@@ -6,8 +6,6 @@ import tempfile
from collections.abc import Awaitable, Callable
from typing import Any, Optional
import anyio
# 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].
@@ -97,6 +95,7 @@ class PyMuPDFProcessor(DocumentProcessor):
Raises:
ProcessorError: If PDF processing fails
"""
import anyio
try:
if progress_callback:
@@ -3,7 +3,6 @@
import logging
from typing import Any
import anyio
from fastembed import SparseTextEmbedding
logger = logging.getLogger(__name__)
@@ -68,6 +67,7 @@ class BM25SparseEmbeddingProvider:
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]
@@ -82,6 +82,7 @@ class BM25SparseEmbeddingProvider:
Returns:
List of dictionaries with 'indices' and 'values' for each text
"""
import anyio
# Run CPU-bound BM25 encoding in thread pool to avoid blocking event loop
sparse_embeddings = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
-45
View File
@@ -1,45 +0,0 @@
"""Centralized HTTP client factory for Nextcloud connections.
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
these factories to ensure consistent SSL/TLS configuration from environment
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
"""
from typing import Any
import httpx
from .config import get_nextcloud_ssl_verify
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
takes precedence if explicitly provided.
Args:
**kwargs: Forwarded to ``httpx.AsyncClient()``.
Returns:
Configured ``httpx.AsyncClient``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncClient(**kwargs)
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
Used by ``NextcloudClient`` which wraps the transport in
``AsyncDisableCookieTransport``.
Args:
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
Returns:
Configured ``httpx.AsyncHTTPTransport``.
"""
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
return httpx.AsyncHTTPTransport(**kwargs)
+3 -2
View File
@@ -6,12 +6,10 @@ provides CLI integration.
"""
import logging
import sqlite3
from pathlib import Path
from alembic.config import Config
import nextcloud_mcp_server.alembic as alembic_package
from alembic import command
logger = logging.getLogger(__name__)
@@ -31,6 +29,8 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
Returns:
Alembic Config object configured for the specified database
"""
from nextcloud_mcp_server import alembic as alembic_package
# Use package location (works in both editable and installed modes)
if alembic_package.__file__ is None:
raise RuntimeError("alembic package __file__ is None")
@@ -98,6 +98,7 @@ def get_current_revision(database_path: str | Path | None = None) -> str | None:
Returns:
Current revision ID or None if not versioned
"""
import sqlite3
if database_path is None:
database_path = "/app/data/tokens.db"
-78
View File
@@ -1,78 +0,0 @@
"""Pydantic response models for Login Flow v2 auth tools."""
from pydantic import Field
from nextcloud_mcp_server.models.base import BaseResponse
class ProvisionAccessResponse(BaseResponse):
"""Response from nc_auth_provision_access tool."""
status: str = Field(
description="Provisioning status: 'login_required', 'already_provisioned', 'declined', 'cancelled', 'error'"
)
login_url: str | None = Field(
None, description="URL to open in browser for Nextcloud login"
)
message: str = Field(description="Human-readable status message")
user_id: str | None = Field(None, description="MCP user ID")
requested_scopes: list[str] | None = Field(
None, description="Scopes requested in this provisioning flow"
)
class ProvisionStatusResponse(BaseResponse):
"""Response from nc_auth_check_status tool."""
status: str = Field(
description="Status: 'provisioned', 'pending', 'not_initiated', 'error'"
)
message: str = Field(description="Human-readable status message")
user_id: str | None = Field(None, description="MCP user ID")
scopes: list[str] | None = Field(
None, description="Granted scopes (None = all scopes)"
)
username: str | None = Field(None, description="Nextcloud username (loginName)")
class UpdateScopesResponse(BaseResponse):
"""Response from nc_auth_update_scopes tool."""
status: str = Field(
description="Status: 'login_required', 'unchanged', 'declined', 'cancelled', 'error'"
)
login_url: str | None = Field(
None, description="URL for re-provisioning with new scopes"
)
message: str = Field(description="Human-readable status message")
previous_scopes: list[str] | None = Field(
None, description="Previously granted scopes"
)
new_scopes: list[str] | None = Field(None, description="Updated scope set")
# All supported application-level scopes (frozenset for O(1) membership tests)
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
{
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"tables:read",
"tables:write",
"deck:read",
"deck:write",
"cookbook:read",
"cookbook:write",
"sharing:read",
"sharing:write",
"news:read",
"news:write",
}
)
-6
View File
@@ -34,12 +34,6 @@ class CalendarEventSummary(BaseModel):
status: Optional[str] = Field(
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
)
calendar_name: Optional[str] = Field(
None, description="Calendar containing this event"
)
calendar_display_name: Optional[str] = Field(
None, description="Display name of calendar containing this event"
)
class CalendarEvent(CalendarEventSummary):
-14
View File
@@ -261,20 +261,6 @@ class CreateLabelResponse(BaseResponse):
color: str = Field(description="The created label color")
class ListCardsResponse(BaseResponse):
"""Response model for listing deck cards."""
cards: list[DeckCard] = Field(description="List of deck cards")
total: int = Field(description="Total number of cards")
class ListLabelsResponse(BaseResponse):
"""Response model for listing deck labels."""
labels: list[DeckLabel] = Field(description="List of deck labels")
total: int = Field(description="Total number of labels")
class LabelOperationResponse(StatusResponse):
"""Response model for label operations like update/delete."""
@@ -14,9 +14,7 @@ and resource usage. Metrics are organized by category:
- External Dependency Health Metrics
"""
import functools
import logging
import time
from prometheus_client import (
Counter,
@@ -25,8 +23,6 @@ from prometheus_client import (
start_http_server,
)
from nextcloud_mcp_server.observability.tracing import trace_operation
logger = logging.getLogger(__name__)
# =============================================================================
@@ -427,6 +423,10 @@ def instrument_tool(func):
Returns:
Wrapped function with metrics and tracing instrumentation
"""
import functools
import time
from nextcloud_mcp_server.observability.tracing import trace_operation
@functools.wraps(func)
async def wrapper(*args, **kwargs):
+7 -7
View File
@@ -1,16 +1,9 @@
"""Base interfaces and data structures for search algorithms."""
import logging
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Protocol, runtime_checkable
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@runtime_checkable
class NextcloudClientProtocol(Protocol):
@@ -85,6 +78,13 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
>>> if "note" in types:
... # Search notes
"""
import logging
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
settings = get_settings()
+26 -7
View File
@@ -7,14 +7,7 @@ position markers for better visualization and understanding of search results.
import logging
from dataclasses import dataclass
import pymupdf
import pymupdf4llm
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
@@ -38,6 +31,11 @@ async def _get_chunk_from_qdrant(
Full chunk text from Qdrant excerpt field, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -103,6 +101,11 @@ async def _get_chunk_by_index_from_qdrant(
Full chunk text from Qdrant excerpt field, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -159,6 +162,11 @@ async def _get_file_path_from_qdrant(
File path string, or None if not found in Qdrant
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -214,6 +222,11 @@ async def _get_deck_metadata_from_qdrant(
Dictionary with board_id and stack_id, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
@@ -339,6 +352,8 @@ async def get_chunk_with_context(
# Fetch adjacent chunks for context expansion
# Get chunk overlap from config to remove duplicate text
from nextcloud_mcp_server.config import get_settings
settings = get_settings()
chunk_overlap = settings.document_chunk_overlap
@@ -534,6 +549,8 @@ async def _fetch_document_text(
# 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")
@@ -569,6 +586,8 @@ async def _fetch_document_text(
return None
elif doc_type == "news_item":
# Fetch news item by ID
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_id))
# Reconstruct full content as indexed: title + source + URL + body
# This ensures chunk offsets align with indexed content structure
+15 -6
View File
@@ -10,16 +10,10 @@ varies between indexing and rendering.
import logging
import re
import shutil
import tempfile
from collections import defaultdict
from io import BytesIO
from pathlib import Path
from typing import Optional
import pymupdf
import pymupdf4llm
from PIL import Image, ImageDraw
logger = logging.getLogger(__name__)
@@ -83,6 +77,8 @@ class PDFHighlighter:
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 = []
@@ -114,6 +110,7 @@ class PDFHighlighter:
full_text = "".join(text_parts)
# Clean up temp directory and extracted images
import shutil
try:
shutil.rmtree(temp_dir)
@@ -593,6 +590,8 @@ class PDFHighlighter:
Returns:
Tuple of (png_bytes, page_number, highlight_count) or None if failed
"""
import tempfile
from pathlib import Path
temp_pdf_path = None
try:
@@ -685,6 +684,8 @@ class PDFHighlighter:
# Clean up temp directory and PDF file
if temp_pdf_path and temp_pdf_path.parent.exists():
try:
import shutil
shutil.rmtree(temp_pdf_path.parent)
except Exception as e:
logger.warning(
@@ -721,6 +722,11 @@ class PDFHighlighter:
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
Chunks that fail to highlight are omitted from the result.
"""
import shutil
import tempfile
from collections import defaultdict
from pathlib import Path
results: dict[int, tuple[bytes, int, int]] = {}
if not chunks:
@@ -794,6 +800,9 @@ class PDFHighlighter:
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
# This avoids expensive page.get_pixmap() calls per chunk
from io import BytesIO
from PIL import Image, ImageDraw
# PIL color for bounding box (RGB tuple)
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
-493
View File
@@ -1,493 +0,0 @@
"""MCP tools for Login Flow v2 authentication (ADR-022).
Provides tools for users to provision Nextcloud access via Login Flow v2,
check provisioning status, and update granted scopes.
These tools work alongside (not replacing) the existing OAuth provisioning
tools during the migration period.
"""
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth.elicitation import present_login_url
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.scope_authorization import (
invalidate_scope_cache,
require_scopes,
)
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.models.auth import (
ALL_SUPPORTED_SCOPES,
ProvisionAccessResponse,
ProvisionStatusResponse,
UpdateScopesResponse,
)
logger = logging.getLogger(__name__)
def register_auth_tools(mcp: FastMCP) -> None:
"""Register Login Flow v2 auth tools with the MCP server."""
@mcp.tool(
name="nc_auth_provision_access",
title="Provision Nextcloud Access",
description=(
"Start Nextcloud Login Flow v2 to obtain an app password. "
"This is required before using any Nextcloud tools. "
"You will be given a URL to open in your browser to log in."
),
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_provision_access(
ctx: Context,
scopes: list[str] | None = None,
) -> ProvisionAccessResponse:
"""Provision Nextcloud access via Login Flow v2.
Args:
ctx: MCP context
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
If not specified, all available scopes are requested.
Returns:
ProvisionAccessResponse with login URL or status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return ProvisionAccessResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
storage = await get_shared_storage()
# Check if already provisioned
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
return ProvisionAccessResponse(
status="already_provisioned",
message=(
f"Nextcloud access already provisioned for {user_id}. "
f"Scopes: {existing['scopes'] or 'all'}. "
f"Use nc_auth_update_scopes to modify permissions."
),
user_id=user_id,
requested_scopes=existing["scopes"],
)
# Determine scopes
requested_scopes = scopes if scopes else list(ALL_SUPPORTED_SCOPES)
# Validate requested scopes
invalid_scopes = [s for s in requested_scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid_scopes:
return ProvisionAccessResponse(
status="error",
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
success=False,
)
# Initiate Login Flow v2
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return ProvisionAccessResponse(
status="error",
message="NEXTCLOUD_HOST not configured on the server.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
init_response = await flow_client.initiate()
except Exception as e:
logger.error(f"Failed to initiate Login Flow v2: {e}")
return ProvisionAccessResponse(
status="error",
message=f"Failed to start login flow: {e}",
success=False,
)
# Store the polling session
await storage.store_login_flow_session(
user_id=user_id,
poll_token=init_response.poll_token,
poll_endpoint=init_response.poll_endpoint,
requested_scopes=requested_scopes,
)
# Present login URL to user via elicitation
elicitation_result = await present_login_url(ctx, init_response.login_url)
if elicitation_result == "declined":
await storage.delete_login_flow_session(user_id)
return ProvisionAccessResponse(
status="declined",
message="Login flow declined. Call nc_auth_provision_access again to retry.",
user_id=user_id,
success=False,
)
if elicitation_result == "cancelled":
await storage.delete_login_flow_session(user_id)
return ProvisionAccessResponse(
status="cancelled",
message="Login flow cancelled. Call nc_auth_provision_access again to retry.",
user_id=user_id,
success=False,
)
message = (
f"Please open this URL in your browser to log in to Nextcloud:\n\n"
f"{init_response.login_url}\n\n"
f"After logging in, call nc_auth_check_status to complete provisioning."
)
if elicitation_result == "accepted":
message = (
"Login acknowledged. Call nc_auth_check_status to verify "
"and complete provisioning."
)
return ProvisionAccessResponse(
status="pending",
login_url=init_response.login_url,
message=message,
user_id=user_id,
requested_scopes=requested_scopes,
)
return ProvisionAccessResponse(
status="login_required",
login_url=init_response.login_url,
message=message,
user_id=user_id,
requested_scopes=requested_scopes,
)
@mcp.tool(
name="nc_auth_check_status",
title="Check Nextcloud Access Status",
description=(
"Check if Nextcloud access has been provisioned. "
"If a Login Flow is pending, this will poll for completion. "
"Recommended polling interval: 5 seconds."
),
annotations=ToolAnnotations(
readOnlyHint=True,
idempotentHint=True,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_check_status(
ctx: Context,
) -> ProvisionStatusResponse:
"""Check provisioning status and poll pending Login Flows.
Returns:
ProvisionStatusResponse with current status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return ProvisionStatusResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
storage = await get_shared_storage()
# Check for existing app password
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
return ProvisionStatusResponse(
status="provisioned",
message=f"Nextcloud access is provisioned for {existing.get('username') or user_id}.",
user_id=user_id,
scopes=existing["scopes"],
username=existing.get("username"),
)
# Check for pending login flow session
try:
session = await storage.get_login_flow_session(user_id)
except Exception as e:
logger.error(f"Failed to check login flow session for {user_id}: {e}")
return ProvisionStatusResponse(
status="error",
message=f"Failed to check login flow session: {e}",
user_id=user_id,
success=False,
)
if not session:
return ProvisionStatusResponse(
status="not_initiated",
message=(
"No provisioning in progress. "
"Call nc_auth_provision_access to start."
),
user_id=user_id,
)
# Poll the Login Flow
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return ProvisionStatusResponse(
status="error",
message="NEXTCLOUD_HOST not configured.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
poll_result = await flow_client.poll(
poll_endpoint=session["poll_endpoint"],
poll_token=session["poll_token"],
)
except Exception as e:
logger.error(f"Failed to poll Login Flow v2: {e}")
return ProvisionStatusResponse(
status="error",
message=f"Failed to check login status: {e}",
success=False,
)
if poll_result.status == "completed":
# Store the app password with scopes
if poll_result.app_password is None:
return ProvisionStatusResponse(
status="error",
message="Login Flow completed but no app password was returned.",
success=False,
)
await storage.store_app_password_with_scopes(
user_id=user_id,
app_password=poll_result.app_password,
scopes=session.get("requested_scopes"),
username=poll_result.login_name,
)
invalidate_scope_cache(user_id)
# Clean up the flow session
await storage.delete_login_flow_session(user_id)
return ProvisionStatusResponse(
status="provisioned",
message=f"Nextcloud access provisioned successfully as {poll_result.login_name}.",
user_id=user_id,
scopes=session.get("requested_scopes"),
username=poll_result.login_name,
)
if poll_result.status == "expired":
# Clean up expired session
await storage.delete_login_flow_session(user_id)
return ProvisionStatusResponse(
status="not_initiated",
message=(
"Login flow expired. "
"Call nc_auth_provision_access to start a new one."
),
user_id=user_id,
)
# Still pending
return ProvisionStatusResponse(
status="pending",
message=(
"Login flow is still pending. "
"Please complete the login in your browser, then call this tool again."
),
user_id=user_id,
)
@mcp.tool(
name="nc_auth_update_scopes",
title="Update Nextcloud Access Scopes",
description=(
"Update the scopes for your Nextcloud access. "
"This starts a new Login Flow with the combined scope set. "
"The current app password remains valid until the new one is obtained."
),
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_update_scopes(
ctx: Context,
add_scopes: list[str] | None = None,
remove_scopes: list[str] | None = None,
) -> UpdateScopesResponse:
"""Update granted scopes by re-provisioning with merged scope set.
Args:
ctx: MCP context
add_scopes: Scopes to add to the current set
remove_scopes: Scopes to remove from the current set
Returns:
UpdateScopesResponse with new login URL or status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return UpdateScopesResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
if not add_scopes and not remove_scopes:
return UpdateScopesResponse(
status="error",
message="Provide add_scopes and/or remove_scopes to update.",
success=False,
)
storage = await get_shared_storage()
# Get current state - require existing provisioning
existing = await storage.get_app_password_with_scopes(user_id)
if existing is None:
return UpdateScopesResponse(
status="error",
message="Not provisioned. Call nc_auth_provision_access first.",
success=False,
)
previous_scopes = existing["scopes"]
# Compute new scope set
current_set = (
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
)
if add_scopes:
invalid = [s for s in add_scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
return UpdateScopesResponse(
status="error",
message=f"Invalid scopes: {', '.join(invalid)}",
success=False,
)
current_set.update(add_scopes)
if remove_scopes:
current_set -= set(remove_scopes)
new_scopes = sorted(current_set)
if not new_scopes:
return UpdateScopesResponse(
status="error",
message="Cannot remove all scopes. At least one scope must remain.",
success=False,
)
# No-op detection: skip Login Flow if scopes are unchanged
previous_scopes_set = (
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
)
if set(new_scopes) == previous_scopes_set:
return UpdateScopesResponse(
status="unchanged",
message="Requested scopes match current scopes. No changes needed.",
previous_scopes=previous_scopes,
new_scopes=new_scopes,
)
# Initiate new Login Flow v2
# Note: existing app password stays valid until the new flow completes.
# store_app_password_with_scopes() does an upsert, so the old password
# is replaced atomically when nc_auth_check_status stores the new one.
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return UpdateScopesResponse(
status="error",
message="NEXTCLOUD_HOST not configured.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
init_response = await flow_client.initiate()
except Exception as e:
logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}")
return UpdateScopesResponse(
status="error",
message=f"Failed to start re-provisioning flow: {e}",
success=False,
)
# Store new flow session
await storage.store_login_flow_session(
user_id=user_id,
poll_token=init_response.poll_token,
poll_endpoint=init_response.poll_endpoint,
requested_scopes=new_scopes,
)
# Present login URL
elicitation_result = await present_login_url(ctx, init_response.login_url)
if elicitation_result == "declined":
await storage.delete_login_flow_session(user_id)
return UpdateScopesResponse(
status="declined",
message="Scope update declined. Call nc_auth_update_scopes again to retry.",
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
success=False,
)
if elicitation_result == "cancelled":
await storage.delete_login_flow_session(user_id)
return UpdateScopesResponse(
status="cancelled",
message="Scope update cancelled. Call nc_auth_update_scopes again to retry.",
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
success=False,
)
message = (
f"Scope update requires re-authentication.\n\n"
f"Please open this URL to log in:\n{init_response.login_url}\n\n"
f"After logging in, call nc_auth_check_status to complete."
)
if elicitation_result == "accepted":
message = (
"Login acknowledged for scope update. "
"Call nc_auth_check_status to verify and complete."
)
return UpdateScopesResponse(
status="login_required",
login_url=init_response.login_url,
message=message,
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
)
+8 -62
View File
@@ -9,46 +9,15 @@ from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.calendar import (
Calendar,
CalendarEventSummary,
ListCalendarsResponse,
ListEventsResponse,
ListTodosResponse,
Todo,
UpcomingEventsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
raw_categories = event.get("categories", [])
if isinstance(raw_categories, str):
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
else:
categories = raw_categories
start = event.get("start_datetime", "")
if not start:
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
return CalendarEventSummary(
uid=event.get("uid", ""),
summary=event.get("title", ""),
start=start,
end=event.get("end_datetime"),
all_day=event.get("all_day", False),
location=event.get("location") or None,
description=event.get("description") or None,
categories=categories,
status=event.get("status"),
calendar_name=event.get("calendar_name"),
calendar_display_name=event.get("calendar_display_name")
or event.get("calendar_name"),
)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool(
@@ -235,7 +204,7 @@ def configure_calendar_tools(mcp: FastMCP):
end_datetime=end_datetime,
filters=filters if filters else None,
)
events = events[:limit]
return events[:limit]
else:
# Search in specific calendar
events = await client.calendar.get_calendar_events(
@@ -245,25 +214,11 @@ def configure_calendar_tools(mcp: FastMCP):
limit=limit,
)
# Enrich events with calendar context for per-event mapping.
# Note: calendar_display_name is not available here without an
# extra list_calendars() call; the response-level calendar_name
# already identifies the calendar for single-calendar queries.
for event in events:
event["calendar_name"] = calendar_name
# Apply filters if provided
if filters:
events = client.calendar._apply_event_filters(events, filters)
summaries = [_event_dict_to_summary(e) for e in events]
return ListEventsResponse(
events=summaries,
calendar_name=None if search_all_calendars else calendar_name,
start_date=start_date or None,
end_date=end_date or None,
total_found=len(summaries),
)
return events
@mcp.tool(
title="Get Calendar Event",
@@ -465,15 +420,12 @@ def configure_calendar_tools(mcp: FastMCP):
if calendar_name:
# Get events from specific calendar
events = await client.calendar.get_calendar_events(
return await client.calendar.get_calendar_events(
calendar_name=calendar_name,
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
# calendar_display_name not available without extra API call
for event in events:
event["calendar_name"] = calendar_name
else:
# Get events from all calendars
all_calendars = await client.calendar.list_calendars()
@@ -481,16 +433,17 @@ def configure_calendar_tools(mcp: FastMCP):
for calendar in all_calendars:
try:
cal_events = await client.calendar.get_calendar_events(
events = await client.calendar.get_calendar_events(
calendar_name=calendar["name"],
start_datetime=now,
end_datetime=end_datetime,
limit=limit,
)
for event in cal_events:
# Add calendar info to each event
for event in events:
event["calendar_name"] = calendar["name"]
event["calendar_display_name"] = calendar["display_name"]
all_events.extend(cal_events)
all_events.extend(events)
except Exception as e:
logger.warning(
f"Error getting events from calendar {calendar['name']}: {e}"
@@ -499,14 +452,7 @@ def configure_calendar_tools(mcp: FastMCP):
# Sort by start time and limit
all_events.sort(key=lambda x: x.get("start_datetime", ""))
events = all_events[:limit]
summaries = [_event_dict_to_summary(e) for e in events]
return UpcomingEventsResponse(
events=summaries,
days_ahead=days_ahead,
calendar_name=calendar_name or None,
)
return all_events[:limit]
@mcp.tool(
title="Find Availability",
+8 -124
View File
@@ -1,95 +1,15 @@
import logging
from typing import Any
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.contacts import (
AddressBook,
Contact,
ContactField,
ListAddressBooksResponse,
ListContactsResponse,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def _parse_vcard_fields(
raw_values: str | dict | list | None, field_type: str
) -> list[ContactField]:
"""Parse polymorphic vCard field data into a list of ContactField.
pythonvCard4 returns field values in several shapes:
- ``str`` plain value, e.g. ``"alice@example.com"``
- ``dict`` ``{'value': '...', 'type': ['HOME', 'PREF']}``
- ``list`` a list whose items are any of the above
The ``PREF`` type parameter is treated as a *preferred* flag rather than a
label. All other type values are lowercased and joined with ``", "``.
"""
if raw_values is None:
return []
items: list[str | dict] = (
raw_values if isinstance(raw_values, list) else [raw_values]
)
fields: list[ContactField] = []
for item in items:
if isinstance(item, dict):
value = str(item.get("value", ""))
if not value:
continue
raw_types: list[str] = item.get("type") or []
preferred = any(t.upper() == "PREF" for t in raw_types)
labels = [t.lower() for t in raw_types if t.upper() != "PREF"]
fields.append(
ContactField(
type=field_type,
value=value,
label=", ".join(labels) if labels else None,
preferred=preferred,
)
)
elif isinstance(item, str) and item:
fields.append(ContactField(type=field_type, value=item))
return fields
def _raw_contact_to_model(raw: dict) -> Contact:
"""Convert a raw contact dict from the contacts client to a Contact model.
Maps fullname, nickname, birthday, email, and tel fields.
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
or lists of either see :func:`_parse_vcard_fields`.
"""
contact_info = raw.get("contact", {})
emails = _parse_vcard_fields(contact_info.get("email"), "email")
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
# Nickname goes into custom_fields (no dedicated model field)
custom_fields: dict[str, Any] = {}
nickname = contact_info.get("nickname")
if nickname:
custom_fields["nickname"] = nickname
return Contact(
uid=raw["vcard_id"],
fn=contact_info.get("fullname", ""),
etag=raw.get("getetag"),
birthday=contact_info.get("birthday"),
emails=emails,
phones=phones,
custom_fields=custom_fields,
)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool(
@@ -98,23 +18,10 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context) -> ListAddressBooksResponse:
async def nc_contacts_list_addressbooks(ctx: Context):
"""List all addressbooks for the user."""
client = await get_client(ctx)
addressbooks_data = await client.contacts.list_addressbooks()
addressbooks = [
AddressBook(
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
# all tools use it as a path segment: f"{carddav_path}/{name}/"
uri=ab["name"],
displayname=ab.get("display_name", ab["name"]),
ctag=ab.get("getctag"),
)
for ab in addressbooks_data
]
return ListAddressBooksResponse(
addressbooks=addressbooks, total_count=len(addressbooks)
)
return await client.contacts.list_addressbooks()
@mcp.tool(
title="List Contacts",
@@ -122,22 +29,10 @@ def configure_contacts_tools(mcp: FastMCP):
)
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(
ctx: Context, *, addressbook: str
) -> ListContactsResponse:
"""List all contacts in the specified addressbook.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
"""
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
"""List all contacts in the specified addressbook."""
client = await get_client(ctx)
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
contacts = [_raw_contact_to_model(c) for c in contacts_data]
return ListContactsResponse(
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
)
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool(
title="Create Address Book",
@@ -184,9 +79,7 @@ def configure_contacts_tools(mcp: FastMCP):
"""Create a new contact.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
addressbook: The name of the addressbook to create the contact in.
uid: The unique ID for the contact.
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
"""
@@ -204,14 +97,7 @@ def configure_contacts_tools(mcp: FastMCP):
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
"""Delete a contact.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
uid: The unique ID of the contact to delete.
"""
"""Delete a contact."""
client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@@ -227,9 +113,7 @@ def configure_contacts_tools(mcp: FastMCP):
"""Update an existing contact while preserving all existing properties.
Args:
addressbook: The URI slug of the addressbook (e.g. "contacts"),
not the display name. Use nc_contacts_list_addressbooks to
find available URI slugs.
addressbook: The name of the addressbook containing the contact.
uid: The unique ID of the contact to update.
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
etag: Optional ETag for optimistic concurrency control.

Some files were not shown because too many files have changed in this diff Show More