Compare commits

..

1 Commits

Author SHA1 Message Date
smithery-ai[bot] 3b48c904f5 Update README 2025-12-11 12:59:56 +00:00
154 changed files with 538 additions and 38888 deletions
@@ -1,89 +0,0 @@
name: Build and Publish Astrolabe App Release
on:
push:
tags:
- 'astrolabe-v*'
env:
APP_NAME: astrolabe
APP_DIR: third_party/astrolabe
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@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
@@ -1,275 +0,0 @@
# 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"
+12 -138
View File
@@ -7,9 +7,9 @@ on:
jobs:
bump-version:
if: "!startsWith(github.event.head_commit.message, 'bump:') && !startsWith(github.event.head_commit.message, 'chore(release):')"
if: "!startsWith(github.event.head_commit.message, 'bump:')"
runs-on: ubuntu-latest
name: "Bump version and create changelog for monorepo components"
name: "Bump version and create changelog with commitizen"
permissions:
contents: write
packages: write
@@ -19,140 +19,14 @@ jobs:
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
- name: Set up Python
uses: actions/setup-python@v5
- name: Create bump and changelog
uses: commitizen-tools/commitizen-action@bb4f1df6601e2a1a891506581b0c53acdc88e07d # 0.26.0
with:
python-version: '3.11'
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
- name: Configure git
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Detect and bump component versions
id: bump
run: |
set -euo pipefail
# Track which components were bumped
BUMPED_COMPONENTS=""
# Helper function to check for commits with specific scope since last tag
has_commits_since_tag() {
local tag_pattern="$1"
local scope_pattern="$2"
# Get the most recent tag matching the pattern
local last_tag=$(git tag --sort=-creatordate | grep -E "^${tag_pattern}" | head -n 1 || echo "")
if [ -z "$last_tag" ]; then
# No previous tag, check all commits on master
local commit_range="master"
else
# Check commits since last tag
local commit_range="${last_tag}..HEAD"
fi
# Count commits matching the scope pattern
local commit_count=$(git log "$commit_range" --oneline --grep="^${scope_pattern}" -E | wc -l)
if [ "$commit_count" -gt 0 ]; then
echo "Found $commit_count commits for scope '$scope_pattern' since $last_tag"
return 0
else
echo "No commits found for scope '$scope_pattern' since $last_tag"
return 1
fi
}
# Bump MCP server (default - all commits except helm/astrolabe scopes)
echo "Checking MCP server for version bump..."
# Get the most recent MCP tag
last_mcp_tag=$(git tag --sort=-creatordate | grep -E "^v[0-9]" | head -n 1 || echo "")
if [ -z "$last_mcp_tag" ]; then
commit_range="master"
else
commit_range="${last_mcp_tag}..HEAD"
fi
# Count conventional commits that are NOT scoped to helm or astrolabe
mcp_commit_count=$(git log "$commit_range" --oneline --grep="^(feat|fix|docs|refactor|perf|test|build|ci|chore)" -E | \
{ grep -v "(helm)" || true; } | { grep -v "(astrolabe)" || true; } | wc -l)
if [ "$mcp_commit_count" -gt 0 ]; then
echo "Found $mcp_commit_count commits for MCP server since $last_mcp_tag"
echo "Bumping MCP server version..."
./scripts/bump-mcp.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS mcp"
else
echo "No commits found for MCP server since $last_mcp_tag"
fi
# Bump Helm chart (scope: helm)
echo "Checking Helm chart for version bump..."
if has_commits_since_tag "nextcloud-mcp-server-" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:"; then
echo "Bumping Helm chart version..."
./scripts/bump-helm.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS helm"
fi
# Bump Astrolabe (scope: astrolabe)
echo "Checking Astrolabe for version bump..."
if has_commits_since_tag "astrolabe-v" "(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:"; then
echo "Bumping Astrolabe version..."
./scripts/bump-astrolabe.sh
BUMPED_COMPONENTS="$BUMPED_COMPONENTS astrolabe"
fi
# Output summary
if [ -z "$BUMPED_COMPONENTS" ]; then
echo "No components required version bumps"
echo "bumped=false" >> $GITHUB_OUTPUT
else
echo "Bumped components:$BUMPED_COMPONENTS"
echo "bumped=true" >> $GITHUB_OUTPUT
echo "components=$BUMPED_COMPONENTS" >> $GITHUB_OUTPUT
fi
- name: Push tags
if: steps.bump.outputs.bumped == 'true'
run: |
git push
git push --tags
echo "Pushed tags for components:${{ steps.bump.outputs.components }}"
- name: Summary
if: steps.bump.outputs.bumped == 'true'
run: |
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
for component in ${{ steps.bump.outputs.components }}; do
case $component in
mcp)
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
helm)
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
astrolabe)
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
;;
esac
done
echo "" >> $GITHUB_STEP_SUMMARY
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
+2 -3
View File
@@ -2,8 +2,7 @@ name: Build and Publish Docker Image
on:
push:
tags:
- "v*"
tags: ["*"]
jobs:
build-and-push:
@@ -34,7 +33,7 @@ jobs:
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
-2
View File
@@ -38,8 +38,6 @@ jobs:
- name: Run chart-releaser
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
with:
skip_existing: true
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Wait for Nextcloud to be ready
run: |
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Install uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+2 -2
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -56,7 +56,7 @@ jobs:
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
- name: Install Playwright dependencies
run: |
-140
View File
@@ -1,143 +1,3 @@
# Changelog - MCP Server
All notable changes to the Nextcloud MCP Server will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
## v0.56.2 (2025-12-20)
### Fix
- **astrolabe**: screenshots in info.xml
- **astrolabe**: screenshots in info.xml
## v0.56.1 (2025-12-19)
### Fix
- **astrolabe**: Update screenshots
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
## v0.56.0 (2025-12-19)
### Feat
- **ci**: add --increment flag to bump scripts for manual version control
### Fix
- **astrolabe**: add contents:write permission to appstore workflow
- **astrolabe**: update commitizen pattern to properly update info.xml version
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
- **astrolabe**: info.xml
## v0.55.1 (2025-12-19)
### Fix
- **ci**: push all tags explicitly in bump workflow
## v0.55.0 (2025-12-19)
### BREAKING CHANGE
- MCP server now bumps for ANY conventional commit except
those explicitly scoped to helm or astrolabe.
### Feat
- **ci**: implement monorepo-aware version bumping workflow
### Fix
- **ci**: make MCP server default bump target for all non-scoped commits
- **ci**: restrict docker build to MCP server tags only
- **ci**: correct appstore-push-action version to v1.0.4
## v0.54.0 (2025-12-19)
### Feat
- **astrolabe**: add Nextcloud App Store deployment automation
- configure commitizen monorepo with independent versioning
### Fix
- **ci**: improve versioning and error handling
- **ci**: address critical workflow and validation issues
- **astrolabe**: address code review feedback
## v0.53.0 (2025-12-19)
### Feat
- add Alembic database migration system
- make chunk modal title clickable link to documents
- add native Plotly hover styling for clickable points
- add click interactivity to Plotly 3D scatter chart
- improve chunk viewer with fixed navigation and markdown rendering
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
- **auth**: implement refresh token rotation for Nextcloud OIDC
- **astrolabe**: enhance unified search and add webhook management
- **astrolabe**: add webhook management UI to admin settings
- **astrolabe**: add OAuth token refresh and webhook presets
- **search**: add file_path metadata and chunk offsets to search results
- **astrolabe**: use proper icons and thumbnails in unified search
- **astrolabe**: add admin search settings and enhanced UI
- **astrolabe**: add unified search provider with clickable file links
- **astrolabe**: add 3D PCA visualization for semantic search
- **astrolabe**: add Nextcloud PHP app for MCP server management
- **vector-sync**: enable background sync in OAuth mode
### Fix
- **security**: address critical security issues from PR #401 code review
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
- **astrolabe**: revert invalid files_pdfviewer URL for file links
- resolve type checking warnings for CI
- move Alembic to package submodule for Docker compatibility
- update unified search results to match chunk viz display
- **astrolabe**: handle OAuth refresh token rotation
- address critical code review issues (4 fixes)
- resolve CI linting issues for Astroglobe
### Refactor
- **astrolabe**: extract PDF viewer to dedicated component
- **astrolabe**: reframe UI as semantic search service
## v0.52.1 (2025-12-13)
### Perf
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
## v0.52.0 (2025-12-13)
### Feat
- **vector**: add Deck card vector search with visualization support
## v0.51.0 (2025-12-13)
### Feat
- **vector-viz**: add news_item support for links and chunk expansion
## v0.50.2 (2025-12-13)
### Fix
- **news**: revert get_item() to use get_items() + filter
## v0.50.1 (2025-12-12)
### Fix
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.50.0 (2025-12-11)
### Feat
-23
View File
@@ -506,29 +506,6 @@ docker compose exec app php occ user_oidc:provider keycloak
**Nextcloud**: `docker compose exec app php occ ...` for occ commands
**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries
### Querying Nextcloud Application Logs
**Use this pattern** to inspect Nextcloud application logs during debugging:
```bash
# View recent log entries
docker compose exec app cat /var/www/html/data/nextcloud.log | jq | tail
# Filter by app
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.app == "astrolabe")' | tail
# Filter by log level (0=DEBUG, 1=INFO, 2=WARN, 3=ERROR, 4=FATAL)
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.level >= 3)' | tail
# Search for specific messages
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.message | contains("OAuth"))' | tail -20
# View full exception traces
docker compose exec app cat /var/www/html/data/nextcloud.log | jq 'select(.exception != null)' | tail -5
```
**Log Structure**: Each entry is a JSON object with fields: `reqId`, `level`, `time`, `remoteAddr`, `user`, `app`, `method`, `url`, `message`, `userAgent`, `version`, `exception`
**For detailed setup, see**:
- `docs/installation.md` - Installation guide
- `docs/configuration.md` - Configuration options
-116
View File
@@ -1,116 +0,0 @@
# Contributing to Nextcloud MCP Server
## Version Management
This monorepo uses commitizen for version management with **independent versioning** for three components:
### Components
| Component | Scope | Bump Command | Tag Example |
|-----------|-------|--------------|-------------|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
### Commit Message Format
Use conventional commits with **scopes** to target specific components:
```bash
# MCP server changes
feat(mcp): add calendar sync API
fix(mcp): resolve authentication bug
# Helm chart changes
feat(helm): add resource limits
docs(helm): update values documentation
# Astrolabe app changes
feat(astrolabe): add dark mode toggle
fix(astrolabe): resolve search UI bug
```
**Unscoped commits** default to the MCP server:
```bash
feat: add new feature # → MCP server (v0.54.0)
```
### Release Workflow
#### 1. Make Changes with Scoped Commits
```bash
git commit -m "feat(astrolabe): add dark mode toggle"
git commit -m "feat(helm): add ingress annotations"
git commit -m "feat(mcp): add calendar sync"
```
#### 2. Bump Component Versions
```bash
# Bump MCP server (reads commits with scope=mcp or unscoped)
./scripts/bump-mcp.sh
# → Creates tag: v0.54.0
# → Updates: pyproject.toml, Chart.yaml:appVersion
# Bump Helm chart (reads commits with scope=helm)
./scripts/bump-helm.sh
# → Creates tag: nextcloud-mcp-server-0.54.0
# → Updates: Chart.yaml:version
# Bump Astrolabe (reads commits with scope=astrolabe)
./scripts/bump-astrolabe.sh
# → Creates tag: astrolabe-v0.2.0
# → Updates: info.xml, package.json
```
#### 3. Push Tags
```bash
git push --follow-tags
```
### Changelog Filtering
Each component maintains its own `CHANGELOG.md`:
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
### Manual Version Bumps
For specific increments:
```bash
# Patch bump (0.53.0 → 0.53.1)
uv run cz bump --increment PATCH
# Minor bump (0.53.0 → 0.54.0)
uv run cz bump --increment MINOR
# Major bump (0.53.0 → 1.0.0)
uv run cz bump --increment MAJOR
# For non-MCP components, use --config
cd charts/nextcloud-mcp-server
uv run cz --config .cz.toml bump --increment MINOR
```
### Versioning Philosophy
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
### Chart.yaml Version vs appVersion
The Helm chart has TWO version fields:
- **`version`**: Chart packaging version (bumped by `feat(helm):`)
- Example: `0.53.0``0.54.0` when adding resource limits
- **`appVersion`**: MCP server version being deployed (bumped by `feat(mcp):`)
- Example: `"0.53.0"``"0.54.0"` when MCP server releases
This allows the chart to evolve independently from the application.
+4 -8
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:590cad70271b6c1795c6a11fb5c110efca593adbd0d4883cd19c36df6a56467b
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
@@ -12,17 +12,13 @@ RUN apt update && apt install --no-install-recommends --no-install-suggests -y \
WORKDIR /app
COPY pyproject.toml uv.lock README.md .
RUN uv sync --locked --no-dev --no-install-project --no-cache
COPY . .
RUN uv sync --locked --no-dev --no-editable --no-cache
ENV PYTHONUNBUFFERED=1
ENV VIRTUAL_ENV=/app/.venv
ENV PATH=/app/.venv/bin:$PATH
ENV PATH=/app/.vnev/bin:$PATH
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "run", "--host", "0.0.0.0"]
ENTRYPOINT ["/app/.venv/bin/nextcloud-mcp-server", "--host", "0.0.0.0"]
+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:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:590cad70271b6c1795c6a11fb5c110efca593adbd0d4883cd19c36df6a56467b
WORKDIR /app
# Install uv for fast dependency management
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+6 -4
View File
@@ -1,11 +1,12 @@
```markdown
<p align="center">
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
</p>
# Nextcloud MCP Server
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
[![smithery badge](https://smithery.ai/badge/@cbcoutinho/nextcloud-mcp-server)](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
@@ -63,7 +64,7 @@ http://127.0.0.1:8000/mcp
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
@@ -81,7 +82,7 @@ http://127.0.0.1:8000/mcp
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
| **Tables** | 5 | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | Create and manage shares |
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -145,7 +146,7 @@ This enables natural language queries and helps discover related content across
### Features
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
### Advanced Topics
@@ -223,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [Nextcloud](https://nextcloud.com/)
```
-90
View File
@@ -1,90 +0,0 @@
# Alembic configuration file for nextcloud-mcp-server
[alembic]
# Path to migration scripts
script_location = nextcloud_mcp_server/alembic
# Template used to generate migration file names
# Default: %%(rev)s_%%(slug)s
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Timezone for migration timestamps
# Default: utc
timezone = utc
# Max length of characters to apply to the "slug" field
# Default: 40
# truncate_slug_length = 40
# Set to 'true' to run the environment during the 'revision' command
# Default: false
# revision_environment = false
# Set to 'true' to allow .pyc and .pyo files without a source .py file
# Default: false
# sourceless = false
# Version location specification
# Supports single or multiple directories
version_locations = nextcloud_mcp_server/alembic/versions
# Path separator for version locations (required to suppress deprecation warning)
# Use os (for cross-platform compatibility)
path_separator = os
# Set to 'true' to search source files recursively in each "version_locations" directory
# Default: false
# recursive_version_locations = false
# Output encoding used when revision files are written
# Default: utf-8
# output_encoding = utf-8
# Database URL - can be overridden by:
# 1. Passing -x database_url=... to alembic commands
# 2. Setting in environment via get_database_url() in env.py
# Default: sqlite:///app/data/tokens.db
sqlalchemy.url = sqlite+aiosqlite:////app/data/tokens.db
[post_write_hooks]
# Post-write hooks allow you to run scripts after generating migration files
# Example: format migrations with ruff
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = format REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-71
View File
@@ -1,71 +0,0 @@
Database Migrations for nextcloud-mcp-server
============================================
This directory contains Alembic database migrations for the token storage database.
Structure
---------
- env.py: Alembic environment configuration
- script.py.mako: Template for generating new migration files
- versions/: Directory containing migration scripts
Usage
-----
Migrations are managed via the CLI:
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show current database version
uv run nextcloud-mcp-server db current
# Show migration history
uv run nextcloud-mcp-server db history
# Create a new migration (developers only)
uv run nextcloud-mcp-server db migrate "description of changes"
# Downgrade database by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
Direct Alembic Usage
--------------------
You can also use Alembic commands directly:
# Specify database URL via -x flag
uv run alembic -x database_url=sqlite+aiosqlite:////path/to/tokens.db upgrade head
# Or set in alembic.ini and run
uv run alembic upgrade head
uv run alembic current
uv run alembic history
Writing Migrations
------------------
Since we don't use SQLAlchemy models, migrations are written with raw SQL:
def upgrade() -> None:
op.execute("""
ALTER TABLE refresh_tokens
ADD COLUMN new_field TEXT
""")
def downgrade() -> None:
# SQLite doesn't support DROP COLUMN, use table recreation
op.execute("""
CREATE TABLE refresh_tokens_new AS
SELECT user_id, encrypted_token, ... FROM refresh_tokens
""")
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
Migration File Naming
---------------------
Format: YYYYMMDD_HHMM_<revision>_<slug>.py
Example: 20251217_2200_001_initial_schema.py
Notes
-----
- Migrations run automatically when RefreshTokenStorage.initialize() is called
- Existing databases are automatically stamped with the initial version
- SQLite has limited ALTER TABLE support - complex changes require table recreation
-26
View File
@@ -1,26 +0,0 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
"""Apply migration changes to upgrade the database schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Revert migration changes to downgrade the database schema."""
${downgrades if downgrades else "pass"}
@@ -3,9 +3,3 @@
set -euox pipefail
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
# Set overwrite settings for URL generation (needed for OIDC discovery to return correct URLs)
# These ensure that URLs generated by Nextcloud include the correct host:port
php /var/www/html/occ config:system:set overwritehost --value="localhost:8080"
php /var/www/html/occ config:system:set overwriteprotocol --value="http"
php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080"
@@ -1,84 +0,0 @@
#!/bin/bash
set -euox pipefail
echo "Installing and configuring Astrolabe app for testing..."
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
if [ -d /opt/apps/astrolabe ]; then
echo "Development astrolabe app found at /opt/apps/astrolabe"
# Remove any existing astrolabe app in custom_apps (from app store or old symlink)
if [ -e /var/www/html/custom_apps/astrolabe ]; then
echo "Removing existing astrolabe in custom_apps..."
rm -rf /var/www/html/custom_apps/astrolabe
fi
# Create symlink from custom_apps to the mounted development version
# Per Nextcloud docs: apps outside server root need symlinks in server root
echo "Creating symlink: custom_apps/astrolabe -> /opt/apps/astrolabe"
ln -sf /opt/apps/astrolabe /var/www/html/custom_apps/astrolabe
echo "Enabling astrolabe app from /opt/apps (development mode via symlink)"
php /var/www/html/occ app:enable astrolabe
elif [ -d /var/www/html/custom_apps/astrolabe ]; then
echo "astrolabe app directory found in custom_apps (already installed)"
php /var/www/html/occ app:enable astrolabe
else
echo "astrolabe app not found, installing from app store..."
php /var/www/html/occ app:install astrolabe
php /var/www/html/occ app:enable astrolabe
fi
# Configure MCP server URLs in Nextcloud system config
# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network)
# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see)
php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001'
php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001'
# Create OAuth client for Astrolabe app
# The resource_url MUST match what the MCP server expects as token audience
# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier
MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient"
MCP_RESOURCE_URL="http://localhost:8001"
MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback"
echo "Configuring OAuth client for Astrolabe..."
# Check if client already exists
if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then
echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..."
php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true
fi
# Create OAuth client with correct resource_url for MCP server audience
echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL"
CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \
"Astrolabe" \
"$MCP_REDIRECT_URI" \
--client_id="$MCP_CLIENT_ID" \
--type=confidential \
--flow=code \
--token_type=jwt \
--resource_url="$MCP_RESOURCE_URL" \
--allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write")
echo "$CLIENT_OUTPUT"
# Extract client_secret from JSON output
CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";')
if [ -n "$CLIENT_SECRET" ]; then
echo "Configuring Astrolabe client secret in system config..."
php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET"
echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..."
else
echo "⚠ Warning: Could not extract client_secret from OIDC client creation"
fi
# Configure OAuth client ID in system config
echo "Configuring Astrolabe client ID in system config..."
php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID"
echo "✓ Client ID configured: $MCP_CLIENT_ID"
echo "Astrolabe app installed and configured successfully"
-24
View File
@@ -1,24 +0,0 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.54.0"
tag_format = "nextcloud-mcp-server-$version"
version_scheme = "semver"
update_changelog_on_bump = true
major_version_zero = true
# Update chart version only (NOT appVersion)
version_files = [
"Chart.yaml:^version:"
]
# Ignore tags from other components
ignored_tag_formats = [
"v*", # MCP server tags
"astrolabe-v*", # Astrolabe tags
]
# Filter commits by scope
[tool.commitizen.customize]
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(helm\\)(!)?:\\s.+"
message_template = "{{change_type}}(helm): {{message}}"
-746
View File
@@ -1,746 +0,0 @@
# Changelog - Helm Chart
All notable changes to the Helm chart will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
- Initial independent versioning release
- Support for Nextcloud MCP server deployment
- Qdrant subchart integration
- Ollama subchart integration
- Configurable resource limits
- Grafana dashboard annotations
## nextcloud-mcp-server-0.54.0 (2025-12-19)
### Feat
- **ci**: implement monorepo-aware version bumping workflow
- **astrolabe**: add Nextcloud App Store deployment automation
- configure commitizen monorepo with independent versioning
### Fix
- **ci**: improve versioning and error handling
- **ci**: address critical workflow and validation issues
- **astrolabe**: address code review feedback
## nextcloud-mcp-server-0.53.0 (2025-12-19)
### Feat
- add Alembic database migration system
- make chunk modal title clickable link to documents
- add native Plotly hover styling for clickable points
- add click interactivity to Plotly 3D scatter chart
- improve chunk viewer with fixed navigation and markdown rendering
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
- **auth**: implement refresh token rotation for Nextcloud OIDC
- **astrolabe**: enhance unified search and add webhook management
- **astrolabe**: add webhook management UI to admin settings
- **astrolabe**: add OAuth token refresh and webhook presets
- **search**: add file_path metadata and chunk offsets to search results
- **astrolabe**: use proper icons and thumbnails in unified search
- **astrolabe**: add admin search settings and enhanced UI
- **astrolabe**: add unified search provider with clickable file links
- **astrolabe**: add 3D PCA visualization for semantic search
- **astrolabe**: add Nextcloud PHP app for MCP server management
- **vector-sync**: enable background sync in OAuth mode
### Fix
- **security**: address critical security issues from PR #401 code review
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
- **astrolabe**: revert invalid files_pdfviewer URL for file links
- resolve type checking warnings for CI
- move Alembic to package submodule for Docker compatibility
- update unified search results to match chunk viz display
- **astrolabe**: handle OAuth refresh token rotation
- address critical code review issues (4 fixes)
- resolve CI linting issues for Astroglobe
### Refactor
- **astrolabe**: extract PDF viewer to dedicated component
- **astrolabe**: reframe UI as semantic search service
## nextcloud-mcp-server-0.52.1 (2025-12-13)
## nextcloud-mcp-server-0.52.0 (2025-12-13)
## nextcloud-mcp-server-0.51.0 (2025-12-13)
### Feat
- **vector**: add Deck card vector search with visualization support
- **vector-viz**: add news_item support for links and chunk expansion
### Perf
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
## nextcloud-mcp-server-0.50.2 (2025-12-13)
### Fix
- **news**: revert get_item() to use get_items() + filter
## nextcloud-mcp-server-0.50.1 (2025-12-12)
### Fix
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
## nextcloud-mcp-server-0.50.0 (2025-12-11)
### Feat
- add MCP tool annotations for enhanced UX
### Fix
- address PR review feedback
## nextcloud-mcp-server-0.49.2 (2025-12-09)
### Fix
- Update lockfile
## nextcloud-mcp-server-0.49.1 (2025-12-09)
### Fix
- Revert mcp version <1.23
## nextcloud-mcp-server-0.49.0 (2025-12-08)
### Fix
- resolve all type checking errors (8 errors fixed)
- **deps**: update dependency mcp to >=1.23,<1.24
### Perf
- **news**: use direct API endpoint for get_item()
## nextcloud-mcp-server-0.48.5 (2025-11-28)
### Feat
- **news**: add Nextcloud News app integration
### Fix
- **deps**: update dependency pillow to v12
### Refactor
- **news**: simplify vector sync to fetch all items
## nextcloud-mcp-server-0.48.4 (2025-11-23)
### Fix
- Add rate limit retry logic to OpenAI provider
## nextcloud-mcp-server-0.48.3 (2025-11-23)
### Fix
- Increase MCP sampling timeout to 5 minutes for slower LLMs
## nextcloud-mcp-server-0.48.2 (2025-11-23)
### Fix
- Share vector sync state with FastMCP session lifespan via module singleton
## nextcloud-mcp-server-0.48.1 (2025-11-23)
## nextcloud-mcp-server-0.48.0 (2025-11-23)
## nextcloud-mcp-server-0.47.0 (2025-11-23)
### Feat
- Add tag management methods to WebDAV client
- Add OpenAI provider support for embeddings and generation
### Fix
- Share vector sync state with FastMCP session lifespan via module singleton
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
### Refactor
- Move background tasks to server lifespan and deprecate SSE transport
## nextcloud-mcp-server-0.46.2 (2025-11-22)
### Fix
- **smithery**: Enable JSON response format for scanner compatibility
## nextcloud-mcp-server-0.46.1 (2025-11-22)
### Perf
- Optimize vector viz search performance
## nextcloud-mcp-server-0.46.0 (2025-11-22)
### Feat
- Add Smithery CLI deployment support
- Implement ADR-016 Smithery stateless deployment mode
### Fix
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
- **smithery**: Use container runtime pattern for config discovery
- Add Smithery lifespan and auth mode detection
## nextcloud-mcp-server-0.45.0 (2025-11-22)
### Feat
- Add context expansion to semantic search with chunk overlap removal
- Use Ollama native batch API in embed_batch()
- Implement Qdrant placeholder state management
- Switch files to use numeric IDs with file_path resolution
- Implement per-chunk vector visualization with context expansion
### Fix
- Use alpha_composite for proper RGBA highlight blending
- Remove pymupdf.layout.activate() to fix page_chunks behavior
- Centralize PDF processing and generate separate images per chunk
- Set is_placeholder=False in processor to fix search filtering
- Increase placeholder staleness threshold to 5x scan interval
- Add placeholder staleness check to prevent duplicate processing
- Use empty SparseVector instead of None for placeholders
- Return empty array instead of null for query_coords when no results
- Align PDF text extraction between indexing and context expansion
- Update models and viz to use int-only doc_id
- Reconstruct full content for notes to match indexed offsets
- Add async/await, PDF metadata, and type safety fixes
### Refactor
- Simplify PDF text extraction with single to_markdown call
### Perf
- Optimize PDF processing with parallel extraction and single-render highlights
## nextcloud-mcp-server-0.44.1 (2025-11-21)
### Fix
- **deps**: update dependency mcp to >=1.22,<1.23
## nextcloud-mcp-server-0.44.0 (2025-11-19)
### Feat
- Improve vector visualization with static assets and fixes
- Redesign UI to match Nextcloud ecosystem aesthetic
### Fix
- Improve 3D plot rendering with explicit dimensions and window resize support
- Preserve 3D plot camera and improve documentation
- Preserve 3D plot camera position and fix CSS loading
## nextcloud-mcp-server-0.43.0 (2025-11-18)
### Feat
- Replace custom document chunker with LangChain MarkdownTextSplitter
## nextcloud-mcp-server-0.42.0 (2025-11-17)
### Feat
- **viz**: Add dual-score display and improve UI controls
## nextcloud-mcp-server-0.41.0 (2025-11-17)
### Feat
- add configurable fusion algorithms for BM25 hybrid search
- add chunk position tracking to vector indexing and search
- add vector viz template and chunk context endpoint
### Fix
- prevent infinite loop in DocumentChunker with position tracking
- Relax SearchResult validation to support DBSF fusion scores > 1.0
## nextcloud-mcp-server-0.40.0 (2025-11-16)
### Feat
- add unified provider architecture with Amazon Bedrock support
### Fix
- suppress Starlette middleware type warnings in ty checker
## nextcloud-mcp-server-0.39.0 (2025-11-16)
## nextcloud-mcp-server-0.38.0 (2025-11-16)
### Feat
- add concurrent uploads and --force flag to upload command
- implement RAG evaluation framework with CLI tooling
- Add OpenTelemetry tracing to @instrument_tool decorator
- Implement BM25 hybrid search with native Qdrant RRF fusion
### Fix
- download qrels from BEIR ZIP instead of HuggingFace
- Handle named vectors in visualization and semantic search
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
- Update viz routes to use BM25 hybrid search after refactor
### Refactor
- migrate asyncio to anyio for consistent structured concurrency
- replace httpx client with NextcloudClient in upload command
### Perf
- Eliminate double-fetching in semantic search sampling
- fix vector viz search performance and visual encoding
- make note deletion concurrent in upload --force
## nextcloud-mcp-server-0.36.0 (2025-11-15)
### BREAKING CHANGE
- Search algorithms now require Qdrant to be populated.
Vector sync must be enabled and documents indexed for search to work.
### Feat
- Normalize hybrid search RRF scores to 0-1 range
- Enhance vector visualization UI and parallelize search verification
- Add Vector Viz tab to app home page
- Add vector visualization pane with multi-select document types
- Implement custom PCA to remove sklearn dependency
- Add multi-document Protocol with cross-app search support
- Update nc_semantic_search tool with algorithm selection
- Implement unified search algorithm module
### Fix
- Reorder tabs and fix viz pane session access
### Refactor
- Optimize Nextcloud access verification with centralized filtering
- Make all search algorithms query Qdrant payload, not Nextcloud
### Perf
- Exclude vector-sync status polling from distributed tracing
## nextcloud-mcp-server-0.35.0 (2025-11-15)
### Feat
- Enable SSE transport for mcp service and update test fixtures
## nextcloud-mcp-server-0.34.2 (2025-11-13)
### Fix
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
- return all notes when search query is empty
## nextcloud-mcp-server-0.34.0 (2025-11-13)
### Feat
- Complete Phase 5 - Instrument all 93 MCP tools
- Add instrumentation decorator and apply to notes tools (Phase 5)
- Add OAuth token and database metrics (Phases 3-4)
- Add metrics instrumentation for queue, health, and database operations
## nextcloud-mcp-server-0.33.1 (2025-11-13)
### Fix
- Move grafana_folder from labels to annotations
## nextcloud-mcp-server-0.33.0 (2025-11-13)
### Feat
- Add Grafana dashboard and vector sync metric instrumentation
## nextcloud-mcp-server-0.32.1 (2025-11-12)
### Fix
- add dynamic dimension detection for Ollama embedding models
## nextcloud-mcp-server-0.32.0 (2025-11-11)
### Feat
- **ollama**: Pull model on startup if not available in ollama
- add dynamic vector sync status updates with htmx polling
- add webhook management UI and BeforeNodeDeletedEvent support
- validate Nextcloud webhook schemas and document findings
### Fix
- improve webapp tab UI with CSS Grid and viewport-filling container
### Refactor
- move webapp from /user/page to /app
- consolidate database storage for webhooks and OAuth tokens
## nextcloud-mcp-server-0.31.1 (2025-11-10)
### Refactor
- simplify OpenTelemetry tracing configuration
## nextcloud-mcp-server-0.31.0 (2025-11-10)
### Feat
- skip tracing for health and metrics endpoints
### Fix
- add retry logic for ETag conflicts in category change test
- optimize Notes API pagination with pruneBefore parameter
## nextcloud-mcp-server-0.30.0 (2025-11-10)
### Feat
- **helm**: Add document chunking configuration
- **vector**: Add configurable chunk size and overlap for document embedding
- **vector**: Support multiple embedding models with auto-generated collection names
### Fix
- Support in-memory Qdrant for CI testing
## nextcloud-mcp-server-0.29.2 (2025-11-09)
### Fix
- **helm**: Set default strategy to Recreate
## nextcloud-mcp-server-0.29.1 (2025-11-09)
### Fix
- **observability**: isolate metrics endpoint to dedicated port
## nextcloud-mcp-server-0.29.0 (2025-11-09)
### Feat
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
### Fix
- **readiness**: Only check external Qdrant in network mode
## nextcloud-mcp-server-0.28.0 (2025-11-09)
### Feat
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
### Fix
- **vector**: Handle missing 'modified' field in notes gracefully
## nextcloud-mcp-server-0.27.3 (2025-11-09)
### Fix
- **ci**: Use helm dependency build instead of update to use Chart.lock
## nextcloud-mcp-server-0.27.2 (2025-11-09)
### Fix
- **helm**: update Qdrant dependency condition to match new mode structure
## nextcloud-mcp-server-0.27.1 (2025-11-09)
### Feat
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
- add Qdrant local mode support with in-memory and persistent storage
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
- implement MCP sampling for semantic search RAG (ADR-008)
- add optional vector database and semantic search to helm chart
- add vector sync processing status to /user/page endpoint
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
- implement vector sync scanner and processor (ADR-007 Phase 2)
### Fix
- **ci**: add Helm repository setup to chart release workflow
- implement deletion grace period and vector sync status tool
- remove unnecessary urllib3<2.0 constraint
- integrate vector sync tasks with Starlette lifespan for streamable-http
### Refactor
- migrate vector sync from asyncio.Queue to anyio memory object streams
- update to Qdrant query_points API and fix Playwright Keycloak login
## nextcloud-mcp-server-0.26.1 (2025-11-08)
### Fix
- **deps**: update dependency mcp to >=1.21,<1.22
## nextcloud-mcp-server-0.26.0 (2025-11-08)
### Feat
- add real elicitation integration test with python-sdk MCP client
- unify session architecture and enhance login status visibility
### Fix
- Consolidate OAuth callbacks and implement PKCE for all flows
## nextcloud-mcp-server-0.25.0 (2025-11-05)
### BREAKING CHANGE
- All OAuth deployments must be reconfigured to specify
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
choose between multi-audience or token exchange mode.
### Feat
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
### Fix
- Implement proper OAuth resource parameters and PRM-based discovery
- Simplify token verifier to be RFC 7519 compliant
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
- Correct OAuth token audience validation for multi-audience mode
### Refactor
- Eliminate duplicate validation logic in UnifiedTokenVerifier
## nextcloud-mcp-server-0.24.1 (2025-11-04)
### Fix
- **deps**: update dependency mcp to >=1.20,<1.21
## nextcloud-mcp-server-0.24.0 (2025-11-04)
### Feat
- add scope protection to OAuth provisioning tools
- enable authorization services for token exchange in Keycloak
- implement scope-based audience mapping and RFC 9728 support
- integrate token exchange into MCP server application
- implement RFC 8693 Standard Token Exchange for Keycloak
- Add userinfo route/page
- add browser-based user info page with separate OAuth flow
- Implement ADR-004 Progressive Consent foundation (partial)
- Complete ADR-004 Progressive Consent OAuth flows implementation
- Implement ADR-004 Progressive Consent foundation components
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
### Fix
- add missing await for get_nextcloud_client in capabilities resource
- use valid Fernet encryption keys in token exchange tests
- accept resource URL in token audience for Nextcloud JWT tokens
- remove token-exchange-nextcloud scope and accept tokens without audience
- move audience mapper from scope to nextcloud-mcp-server client
- move token-exchange-nextcloud from default to optional scopes
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
- correct OAuth token audience validation using RFC 8707 resource parameter
- remove remaining references to deleted oauth_callback and oauth_token
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
- browser OAuth userinfo endpoint and refresh token rotation
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
- make provisioning checks opt-in (default false)
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
### Refactor
- integrate token exchange into unified get_client() pattern
## nextcloud-mcp-server-0.23.0 (2025-11-03)
### Feat
- Auto-configure impersonation role in Keycloak realm import
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
- Add Keycloak external IdP integration with custom scopes
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
- Add Keycloak OAuth provider support with refresh token storage
### Fix
- Complete Keycloak external IdP integration with all tests passing
- Complete Keycloak external IdP integration with all tests passing
- Update DCR token_type tests for OIDC app changes
### Refactor
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
- Unify OAuth configuration to be provider-agnostic
## nextcloud-mcp-server-0.22.7 (2025-10-29)
### Fix
- **helm**: Remove image tag overide
## nextcloud-mcp-server-0.22.6 (2025-10-29)
### Fix
- **helm**: Update helm chart with extraArgs
## nextcloud-mcp-server-0.22.5 (2025-10-29)
### Fix
- Update helm chart variables
## nextcloud-mcp-server-0.22.4 (2025-10-29)
### Fix
- **helm**: Update helm version with release
- **helm**: Update helm version with release
- **helm**: Update helm version with release
## nextcloud-mcp-server-0.1.1 (2025-10-29)
### Fix
- **helm**: Update helm version with release
- Trigger release
## nextcloud-mcp-server-0.1.0 (2025-10-29)
### BREAKING CHANGE
- FASTMCP_-prefixed env vars have been replaced by CLI
arguments. Refer to the README for updated usage.
### Feat
- **server**: Add /live & /health endpoints
- Initialize helm chart
- Add text processing background worker for telling client about progress
- **auth**: Add support for client registration deletion
- Split read/write scopes into app:read/write scopes
- Enable token introspection for opaque tokens
- **server**: Add support for custom OIDC scopes and permissions via JWTs
- Initialize JWT-scoped tools
- **caldav**: Add support for tasks
- **webdav**: Add search and list favorite response tools
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
- Add Groups API client
- add sharing API client and server tools
- **server**: Experimental support for OAuth2/OIDC authentication
- **users**: Initialize user API client
- **server**: Add support for `streamable-http` transport type
- Add WebDAV resource copy functionality
- Add WebDAV resource move/rename functionality
- **deck**: Add support for stack, cards, labels
- **deck**: Initialize Deck app client/server
- **cli**: Replace `mcp run` with click CLI and runtime options
- **client**: Preserve fields when modifying contacts/calendar resources
- **server**: Add structured output to all tool/resource output
- **contacts**: Initialize Contacts App
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
- Update webdav client create_directory method to handle recursive directories
- **webdav**: add complete file system support
- Add TablesClient and associated tools
- Switch to using async client
- **notes**: Add append to note functionality
### Fix
- Add support for RFC 7592 client registration and deletion
- Update webdav models for proper serialization
- **deps**: update dependency mcp to >=1.19,<1.20
- Add CORS middleware to allow browser-based clients like MCP Inspector
- Use occ-created OAuth clients with allowed_scopes for all tests
- Separate OAuth fixtures for opaque vs JWT tokens
- **caldav**: Fix caldav search() due to missing todos
- **caldav**: Check that calendar exists after creation to avoid race condition
- **caldav**: Properly parse datetimes as vDDDTypes
- Increase HTTP client timeout to 30s
- Handle RequestError in mcp tools
- **deps**: update dependency mcp to >=1.18,<1.19
- **deps**: update dependency pillow to v12
- **oauth**: Remove the option to force_register new clients
- Update user/groups API to OCS v2
- **deps**: update dependency mcp to >=1.17,<1.18
- **deps**: update dependency mcp to >=1.16,<1.17
- **deps**: update dependency mcp to >=1.15,<1.16
- **docker**: Provide --host 0.0.0.0 in default docker image
- **deps**: update dependency mcp to >=1.13,<1.14
- **server**: Replace ErrorResponses with standard McpErrors
- **notes**: Include ETags in responses to avoid accidently updates
- **notes**: Remove note contents from responses to reduce token usage
- **model**: Serialize timestamps in RFC3339 format
- **client**: Use paging to fetch all notes
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
- **calendar**: Fix iCalendar date vs datetime format
- **calendar**: Remove try/except in calendar API
- apply ruff formatting to pass CI checks
- **calendar**: address PR feedback from maintainer
- apply ruff formatting to test_webdav_operations.py
- **deps**: update dependency mcp to >=1.10,<1.11
- update tests
- Commitizen release process
- Do not update dependencies when running in Dockerfile
- Configure logging
- Limit search results to notes with score > 0.5
- Install deps before checking service
- **deps**: update dependency mcp to >=1.9,<1.10
### Refactor
- Transform document parsing into pluggable processor architecture
- Update JWT client to use DCR, re-enable tool filtering
- Migrate from internal CalendarClient to caldav library
- Unify logging & remove factory deployment
- Add tools for all resources to enable tool-only workflows
- Add `http` to --transport option
- Use _make_request where available
- **calendar**: optimize logging for production readiness
- Modularize NC and Notes app client
### Perf
- **notes**: Improve notes search performance using async iterators
+3 -3
View File
@@ -4,6 +4,6 @@ dependencies:
version: 1.16.2
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.36.0
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
generated: "2025-12-14T11:07:07.024787592Z"
version: 1.35.0
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
generated: "2025-12-05T11:11:27.999374001Z"
+3 -3
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.54.0
appVersion: "0.56.2"
version: 0.50.0
appVersion: "0.50.0"
keywords:
- nextcloud
- mcp
@@ -31,6 +31,6 @@ dependencies:
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
version: "1.36.0"
version: "1.35.0"
repository: https://otwld.github.io/ollama-helm
condition: ollama.enabled
+2 -11
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
image: docker.io/library/nextcloud:32.0.2@sha256:04cc19547e586ac75e08dd056c11330d4ce4c5c561c89405b326180a37c19afb
restart: always
ports:
- 0.0.0.0:8080:80
@@ -35,7 +35,6 @@ services:
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
#- ./third_party/astrolabe:/opt/apps/astrolabe:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -52,7 +51,7 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
@@ -151,14 +150,6 @@ services:
# Tokens must contain BOTH MCP and Nextcloud audiences
# No token exchange needed - tokens work for both MCP auth and Nextcloud APIs
# Vector sync configuration (ADR-007)
- VECTOR_SYNC_ENABLED=true
- VECTOR_SYNC_SCAN_INTERVAL=60
- VECTOR_SYNC_PROCESSOR_WORKERS=1
# Qdrant configuration - persistent local storage
- QDRANT_LOCATION=/app/data/qdrant
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# JWT token type is used for testing (faster validation, scopes embedded in token)
File diff suppressed because it is too large Load Diff
-104
View File
@@ -1,104 +0,0 @@
# MCP 1.23.x DNS Rebinding Protection Fix
## Problem
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
### Root Cause
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
```python
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
```
### What Was Happening
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
### Why `--host 0.0.0.0` Didn't Help
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
## Solution
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
### Changes Made
Modified `nextcloud_mcp_server/app.py`:
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
2. **Updated all three FastMCP initializations**:
- OAuth mode (line 1015)
- Smithery stateless mode (line 1030)
- BasicAuth mode (line 1040)
Each now includes:
```python
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
```
## Impact
### ✅ What This Fixes
- **Kubernetes deployments**: Requests with k8s service DNS names now work
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
- **Ingress controllers**: Requests via ingress hostnames now work
### 🔒 Security Considerations
DNS rebinding protection defends against attacks where:
1. Attacker controls a DNS domain (e.g., `evil.com`)
2. DNS initially resolves to attacker's IP
3. After victim's browser caches the origin, DNS changes to victim's localhost
4. Attacker's page can now make requests to victim's localhost services
**Why it's safe to disable for this deployment:**
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
4. **Host header validation inappropriate** for multi-tenant k8s environments
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
```python
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[
"nextcloud-mcp-server.default.svc.cluster.local:*",
"mcp.example.com:*",
# Add all your expected Host header values
]
)
```
## Testing
- ✅ Ruff linting passes
- ✅ Type checking passes (pre-existing warnings unrelated)
- ✅ Module imports successfully
- ✅ Compatible with MCP 1.23.x
## References
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
- Issue #373 (original report of k8s breakage)
- PR #382 (MCP 1.23.x upgrade)
-301
View File
@@ -1,301 +0,0 @@
# Database Migrations
This document describes the database migration system for nextcloud-mcp-server's token storage database.
## Overview
The token storage database uses [Alembic](https://alembic.sqlalchemy.org/) for schema versioning and migrations. Alembic provides:
- **Version Control**: Track schema changes in Git
- **Rollback Support**: Safely downgrade schema if needed
- **Audit Trail**: Migration files serve as schema changelog
- **Automated Upgrades**: Database schema updates automatically on startup
## Architecture
### Migration Strategy
The system handles three scenarios:
1. **New Database**: Runs migrations from scratch to create all tables
2. **Pre-Alembic Database**: Stamps existing database with initial revision (no changes)
3. **Alembic-Managed Database**: Upgrades to latest version automatically
### Directory Structure
```
nextcloud-mcp-server/
├── alembic/ # Alembic migrations
│ ├── versions/ # Migration scripts
│ │ └── 20251217_2200_001_initial_schema.py
│ ├── env.py # Alembic environment
│ ├── script.py.mako # Migration template
│ └── README # Migration usage guide
├── alembic.ini # Alembic configuration
└── nextcloud_mcp_server/
├── auth/storage.py # Uses migrations on init
└── migrations.py # Migration utilities
```
## Usage
### Automatic Migration on Startup
Migrations run automatically when the server starts:
```bash
uv run nextcloud-mcp-server
```
The `RefreshTokenStorage.initialize()` method:
1. Checks if database is Alembic-managed
2. Stamps pre-Alembic databases with initial revision
3. Upgrades to latest version
### Manual Migration Commands
```bash
# Show current database version
uv run nextcloud-mcp-server db current
# Upgrade database to latest version
uv run nextcloud-mcp-server db upgrade
# Show migration history
uv run nextcloud-mcp-server db history
# Downgrade by one version (emergency use only)
uv run nextcloud-mcp-server db downgrade
# Specify custom database path
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
```
### Environment Variables
- `TOKEN_STORAGE_DB`: Path to database file (default: `/app/data/tokens.db`)
## Creating Migrations (Developers)
### Step 1: Create Migration File
```bash
uv run nextcloud-mcp-server db migrate "add user preferences table"
```
This creates a new migration file in `alembic/versions/` with empty `upgrade()` and `downgrade()` functions.
### Step 2: Write Migration SQL
Since we don't use SQLAlchemy models, write raw SQL:
```python
def upgrade() -> None:
"""Add user preferences table."""
op.execute("""
CREATE TABLE user_preferences (
user_id TEXT PRIMARY KEY,
theme TEXT DEFAULT 'light',
language TEXT DEFAULT 'en',
created_at INTEGER NOT NULL
)
""")
op.execute("""
CREATE INDEX idx_user_preferences_user_id
ON user_preferences(user_id)
""")
def downgrade() -> None:
"""Remove user preferences table."""
op.execute("DROP INDEX IF EXISTS idx_user_preferences_user_id")
op.execute("DROP TABLE IF EXISTS user_preferences")
```
### Step 3: Test Migration
```bash
# Test upgrade
uv run nextcloud-mcp-server db upgrade -d /tmp/test.db
# Verify schema
sqlite3 /tmp/test.db ".schema"
# Test downgrade
uv run nextcloud-mcp-server db downgrade -d /tmp/test.db
# Verify removal
sqlite3 /tmp/test.db ".schema"
```
### Step 4: Commit Migration
```bash
git add alembic/versions/YYYYMMDD_HHMM_XXX_description.py
git commit -m "feat: add user preferences table migration"
```
## SQLite Limitations
SQLite has limited `ALTER TABLE` support:
### Supported Operations
- ✅ Add columns: `ALTER TABLE table ADD COLUMN ...`
- ✅ Rename table: `ALTER TABLE old RENAME TO new`
- ✅ Rename column: `ALTER TABLE table RENAME COLUMN old TO new` (SQLite 3.25+)
### Unsupported Operations (Requires Table Recreation)
- ❌ Drop column
- ❌ Change column type
- ❌ Add constraints to existing columns
### Table Recreation Pattern
For complex schema changes:
```python
def upgrade() -> None:
# Create new table with desired schema
op.execute("""
CREATE TABLE refresh_tokens_new (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
new_field TEXT, -- New column
expires_at INTEGER,
created_at INTEGER NOT NULL
)
""")
# Copy data from old table
op.execute("""
INSERT INTO refresh_tokens_new
(user_id, encrypted_token, expires_at, created_at)
SELECT user_id, encrypted_token, expires_at, created_at
FROM refresh_tokens
""")
# Drop old table and rename new table
op.execute("DROP TABLE refresh_tokens")
op.execute("ALTER TABLE refresh_tokens_new RENAME TO refresh_tokens")
# Recreate indexes
op.execute("CREATE INDEX idx_user_id ON refresh_tokens(user_id)")
```
## Best Practices
### Naming Conventions
- **Migrations**: `YYYYMMDD_HHMM_XXX_description.py`
- **Revision IDs**: Sequential numbers (`001`, `002`, `003`)
- **Descriptions**: Imperative mood ("add table", "remove column")
### Migration Guidelines
1. **Test Thoroughly**: Test both upgrade and downgrade paths
2. **Preserve Data**: Ensure data migration logic is correct
3. **Document Changes**: Add comments explaining complex operations
4. **Small Changes**: One logical change per migration
5. **No Breaking Changes**: Maintain backward compatibility when possible
### Downgrade Considerations
- **Data Loss**: Downgrade may lose data (dropped columns, tables)
- **Confirmation**: Downgrade command requires explicit confirmation
- **Testing**: Always test downgrade path before deploying
- **Emergency Only**: Use downgrades only for critical rollbacks
## Backward Compatibility
### Pre-Alembic Databases
Existing databases created before Alembic integration are automatically detected and stamped with revision `001`:
1. Server detects no `alembic_version` table
2. Checks if `refresh_tokens` table exists
3. If yes, stamps database with `001` (no schema changes)
4. Future updates use normal migration path
### Migration Path
```
Pre-Alembic DB → Stamp(001) → Upgrade(002) → Upgrade(003) → ...
New DB → Migrate(001) → Upgrade(002) → Upgrade(003) → ...
```
## Troubleshooting
### Migration Fails
```bash
# Check current state
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
# View migration history
uv run nextcloud-mcp-server db history -d /path/to/tokens.db
# Manually inspect database
sqlite3 /path/to/tokens.db ".schema"
```
### Reset to Initial State
**WARNING: This destroys all data!**
```bash
# Downgrade to base (empty database)
uv run nextcloud-mcp-server db downgrade -d /path/to/tokens.db --revision base
# Upgrade to latest
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
### Corrupted Migration State
If `alembic_version` table is corrupted:
```bash
# Manually fix via SQL
sqlite3 /path/to/tokens.db
> DELETE FROM alembic_version;
> INSERT INTO alembic_version (version_num) VALUES ('001');
> .quit
# Verify and upgrade
uv run nextcloud-mcp-server db current -d /path/to/tokens.db
uv run nextcloud-mcp-server db upgrade -d /path/to/tokens.db
```
## CI/CD Integration
### Pre-Deployment
```bash
# Run migrations in test environment
export TOKEN_STORAGE_DB=/app/data/tokens.db
uv run nextcloud-mcp-server db upgrade
# Verify current version
uv run nextcloud-mcp-server db current
```
### Docker Deployment
Migrations run automatically on container startup via `RefreshTokenStorage.initialize()`.
### Rollback Plan
1. Stop application
2. Backup database: `cp tokens.db tokens.db.backup`
3. Downgrade: `uv run nextcloud-mcp-server db downgrade --revision XXX`
4. Deploy previous application version
5. Restart application
## References
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
- [SQLite ALTER TABLE Limitations](https://www.sqlite.org/lang_altertable.html)
- [ADR-004: Progressive Consent](./ADR-004-progressive-consent.md) (migration 001)
+196 -186
View File
@@ -14,10 +14,100 @@ Before running the server:
## Quick Start
Start the server using Docker:
Load your environment variables and start the server:
```bash
# OAuth mode (recommended)
# Load environment variables from .env
export $(grep -v '^#' .env | xargs)
# Start the server
uv run nextcloud-mcp-server
```
The server will start on `http://127.0.0.1:8000` by default.
---
## Running Locally
### Method 1: Using nextcloud-mcp-server CLI (Recommended)
The CLI provides a simple interface with built-in defaults:
#### OAuth Mode
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD not set
uv run nextcloud-mcp-server
# Explicitly force OAuth mode
uv run nextcloud-mcp-server --oauth
# OAuth with custom host and port
uv run nextcloud-mcp-server --oauth --host 0.0.0.0 --port 8080
# OAuth with pre-configured client
uv run nextcloud-mcp-server --oauth \
--oauth-client-id abc123 \
--oauth-client-secret xyz789
# OAuth with specific apps only
uv run nextcloud-mcp-server --oauth \
--enable-app notes \
--enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# Auto-detected when NEXTCLOUD_USERNAME/PASSWORD are set
uv run nextcloud-mcp-server
# Explicitly force BasicAuth mode
uv run nextcloud-mcp-server --no-oauth
# BasicAuth with specific apps
uv run nextcloud-mcp-server --no-oauth \
--enable-app notes \
--enable-app webdav
```
### Method 2: Using uvicorn
For more control over server options (workers, reload, etc.):
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run with uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--host 127.0.0.1 \
--port 8000 \
--reload # Enable auto-reload for development
```
See all uvicorn options at [https://www.uvicorn.org/settings/](https://www.uvicorn.org/settings/)
### Method 3: Using Python Module
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run as Python module
python -m nextcloud_mcp_server.app --oauth --port 8000
```
---
## Running with Docker
### Basic Docker Run
```bash
# OAuth mode
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
@@ -26,56 +116,11 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
```
The server will start on `http://127.0.0.1:8000` by default.
---
## Running with Docker
### Basic Docker Run
#### OAuth Mode (Recommended)
### Docker with Persistent OAuth Storage
```bash
# OAuth with auto-registration
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with custom port
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with pre-configured client
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
-e NEXTCLOUD_OIDC_CLIENT_ID=abc123 \
-e NEXTCLOUD_OIDC_CLIENT_SECRET=xyz789 \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# OAuth with specific apps only
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes --enable-app calendar
```
#### BasicAuth Mode (Legacy)
```bash
# BasicAuth (requires NEXTCLOUD_USERNAME/PASSWORD in .env)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
# BasicAuth with specific apps
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest \
--enable-app notes --enable-app webdav
```
### Docker with Persistent Token Storage
```bash
# Mount volume for persistent OAuth token storage
docker run -p 127.0.0.1:8000:8000 --env-file .env \
-v $(pwd)/data:/app/data \
-v $(pwd)/.oauth:/app/.oauth \
--rm ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
@@ -95,7 +140,7 @@ services:
env_file:
- .env
volumes:
- ./data:/app/data # Persistent token storage
- ./oauth-storage:/app/.oauth
restart: unless-stopped
```
@@ -123,39 +168,30 @@ docker-compose down
```bash
# Bind to all interfaces (accessible from network)
docker run -p 0.0.0.0:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
uv run nextcloud-mcp-server --host 0.0.0.0 --port 8000
# Bind to localhost only (default, more secure)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
uv run nextcloud-mcp-server --host 127.0.0.1 --port 8000
# Use a different port (map host port 8080 to container port 8000)
docker run -p 127.0.0.1:8080:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
# Use a different port
uv run nextcloud-mcp-server --port 8080
```
**Security Note:** Binding to `0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
**Security Note:** Using `--host 0.0.0.0` exposes the server to your network. Only use this if you understand the security implications.
### Transport Protocols
The server supports multiple MCP transport protocols:
```bash
# Streamable HTTP (default, recommended)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport streamable-http
# Streamable HTTP (recommended)
uv run nextcloud-mcp-server --transport streamable-http
# SSE - Server-Sent Events (deprecated)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport sse
# SSE - Server-Sent Events (default, deprecated)
uv run nextcloud-mcp-server --transport sse
# HTTP
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--transport http
uv run nextcloud-mcp-server --transport http
```
> [!WARNING]
@@ -165,14 +201,10 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
```bash
# Set log level (critical, error, warning, info, debug, trace)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level debug
uv run nextcloud-mcp-server --log-level debug
# Production: use warning or error
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level warning
uv run nextcloud-mcp-server --log-level warning
```
### Selective App Enablement
@@ -180,26 +212,22 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
By default, all supported Nextcloud apps are enabled. You can enable specific apps only:
```bash
# Available apps: notes, tables, webdav, calendar, contacts, cookbook, deck
# Available apps: notes, tables, webdav, calendar, contacts, deck
# Enable all apps (default)
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
uv run nextcloud-mcp-server
# Enable only Notes
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes
uv run nextcloud-mcp-server --enable-app notes
# Enable multiple apps
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app notes --enable-app calendar --enable-app contacts
uv run nextcloud-mcp-server \
--enable-app notes \
--enable-app calendar \
--enable-app contacts
# Enable only WebDAV for file operations
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--enable-app webdav
uv run nextcloud-mcp-server --enable-app webdav
```
**Use cases:**
@@ -212,68 +240,24 @@ docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
## Development Mode
### Running for Development
For active development with auto-reload, mount your source code as a volume:
For active development with auto-reload:
```bash
# Development mode with source code mounted
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
-v $(pwd):/app \
-v $(pwd)/data:/app/data \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
# Using uvicorn with reload
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--reload \
--host 127.0.0.1 \
--port 8000 \
--log-level debug
```
For local development without Docker:
Or use the CLI with reload flag:
```bash
# Load environment variables
export $(grep -v '^#' .env | xargs)
# Run the server with auto-reload
uv run nextcloud-mcp-server run --oauth --log-level debug
uv run nextcloud-mcp-server --reload --log-level debug
```
### CLI Subcommands
The `nextcloud-mcp-server` CLI has two main subcommands:
1. **`run`** - Start the MCP server (default command in Docker)
```bash
uv run nextcloud-mcp-server run --oauth --host 0.0.0.0 --port 8000
```
2. **`db`** - Database migration management (Alembic)
```bash
# Show current migration revision
uv run nextcloud-mcp-server db current
# Upgrade to latest migration
uv run nextcloud-mcp-server db upgrade
# Show migration history
uv run nextcloud-mcp-server db history
# Create new migration (developers only)
uv run nextcloud-mcp-server db migrate "description of changes"
```
### Database Migrations
Token storage uses **Alembic** for schema management:
- **Automatic migrations**: Database is upgraded automatically on server startup
- **Backward compatibility**: Pre-Alembic databases are automatically stamped with the initial revision
- **Migration files**: Located in `alembic/versions/`
- **For developers**: When changing the schema:
1. Create a migration: `uv run nextcloud-mcp-server db migrate "add new column"`
2. Edit the generated file in `alembic/versions/` to add SQL statements
3. Test upgrade: `uv run nextcloud-mcp-server db upgrade`
4. Test downgrade: `uv run nextcloud-mcp-server db downgrade`
See [Database Migrations Guide](database-migrations.md) for detailed information.
---
## Connecting to the Server
@@ -282,15 +266,15 @@ See [Database Migrations Guide](database-migrations.md) for detailed information
MCP Inspector is a browser-based tool for testing MCP servers:
1. Start your MCP server using Docker (see above)
2. Start MCP Inspector:
```bash
npx @modelcontextprotocol/inspector
```
3. In the browser:
- Enter server URL: `http://localhost:8000`
- Complete OAuth flow (if using OAuth)
- Explore tools and resources
```bash
# Start MCP Inspector
uv run mcp dev
# In the browser:
# 1. Enter server URL: http://localhost:8000
# 2. Complete OAuth flow (if using OAuth)
# 3. Explore tools and resources
```
### Using MCP Clients
@@ -338,13 +322,48 @@ INFO Initializing Nextcloud client with BasicAuth
### Running as a Background Service
Use Docker Compose with `restart: unless-stopped` (see [Docker Compose section](#docker-compose) above).
#### Using systemd (Linux)
Create `/etc/systemd/system/nextcloud-mcp.service`:
```ini
[Unit]
Description=Nextcloud MCP Server
After=network.target
[Service]
Type=simple
User=your-user
WorkingDirectory=/path/to/nextcloud-mcp-server
EnvironmentFile=/path/to/.env
ExecStart=/path/to/uv run nextcloud-mcp-server --oauth
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Enable and start:
```bash
sudo systemctl daemon-reload
sudo systemctl enable nextcloud-mcp
sudo systemctl start nextcloud-mcp
sudo systemctl status nextcloud-mcp
```
#### Using Docker Compose
See [Docker Compose section](#docker-compose) above - includes `restart: unless-stopped`.
### Monitoring Logs
```bash
# Docker (find container name first)
docker ps
# Local installation with systemd
sudo journalctl -u nextcloud-mcp -f
# Docker
docker logs -f <container-name>
# Docker Compose
@@ -355,37 +374,34 @@ docker-compose logs -f mcp
## Performance Tuning
### Production Settings
### Multiple Workers
For production deployments, use Docker Compose with the recommended settings:
For production deployments with higher load:
```yaml
version: '3.8'
```bash
# Using CLI (if supported)
uv run nextcloud-mcp-server --workers 4
services:
mcp:
image: ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
command: --oauth --log-level warning --transport streamable-http
ports:
- "127.0.0.1:8000:8000"
env_file:
- .env
volumes:
- ./data:/app/data
restart: unless-stopped
deploy:
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 512M
# Using uvicorn
uv run uvicorn nextcloud_mcp_server.app:get_app \
--factory \
--workers 4 \
--host 0.0.0.0 \
--port 8000
```
### Scaling with Multiple Replicas
### Production Settings
For higher load, use Docker Swarm or Kubernetes. See the [Helm Chart](../helm/) for Kubernetes deployments.
```bash
# Recommended production configuration
uv run nextcloud-mcp-server \
--oauth \
--host 127.0.0.1 \
--port 8000 \
--log-level warning \
--transport streamable-http \
--workers 2
```
---
@@ -395,18 +411,12 @@ For higher load, use Docker Swarm or Kubernetes. See the [Helm Chart](../helm/)
Check logs for errors:
```bash
# View container logs
docker logs <container-name>
# Or run with debug logging
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth \
--log-level debug
uv run nextcloud-mcp-server --log-level debug
```
Common issues:
- Environment variables not loaded - Check your `.env` file
- Port already in use - Use a different host port (e.g., `-p 127.0.0.1:8080:8000`)
- Environment variables not loaded - See [Configuration](configuration.md#loading-environment-variables)
- Port already in use - Try a different port with `--port`
- OAuth configuration errors - See [Troubleshooting](troubleshooting.md)
### Can't connect to server
+4 -4
View File
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
> [!IMPORTANT]
> **Status: Experimental**
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
> - RAG answer generation requires MCP client sampling support
@@ -39,9 +39,9 @@ Semantic search enables:
### Current Support
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
- **Architecture**: Multi-app plugin system ready for additional apps
- **Supported Apps**: Notes (fully implemented)
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
- **Architecture**: Multi-app plugin system ready, awaiting implementation
## System Components
-5
View File
@@ -51,11 +51,6 @@ NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
NEXTCLOUD_USERNAME=
NEXTCLOUD_PASSWORD=
# Cookie security (browser UI)
# Auto-detects from NEXTCLOUD_HOST protocol if not set
# Set explicitly for non-standard setups
#COOKIE_SECURE=true
# ============================================
# Document Processing Configuration
# ============================================
-133
View File
@@ -1,133 +0,0 @@
"""Alembic environment configuration for nextcloud-mcp-server.
This module configures how Alembic runs database migrations for the
token storage database. It supports both online and offline migration modes.
Uses anyio for async operations, consistent with the project's async patterns.
"""
import logging
from pathlib import Path
import anyio
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Configure logging
logger = logging.getLogger("alembic.env")
# This is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Update script location to point to package location
# This allows alembic to find migrations when installed in site-packages
script_location = Path(__file__).parent
config.set_main_option("script_location", str(script_location))
# We don't use SQLAlchemy models, so target_metadata is None
# Migrations will be written manually using op.execute() for raw SQL
target_metadata = None
def get_database_url() -> str:
"""
Get the database URL from Alembic config or environment.
The URL can be set in alembic.ini or passed via -x database_url=...
when running Alembic commands.
Returns:
Database URL (SQLite URL format)
"""
# Check if URL is passed via -x database_url=...
url = context.get_x_argument(as_dictionary=True).get("database_url")
if not url:
# Fall back to alembic.ini configuration
url = config.get_main_option("sqlalchemy.url")
if not url:
# Default to /app/data/tokens.db for Docker deployments
db_path = Path("/app/data/tokens.db")
url = f"sqlite+aiosqlite:///{db_path}"
logger.warning(
f"No database URL configured, using default: {url}. "
"Set sqlalchemy.url in alembic.ini or pass -x database_url=..."
)
return url
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL and not an Engine,
though an Engine is acceptable here as well. By skipping the
Engine creation we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
This mode is useful for generating SQL scripts without database access.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
"""Execute migrations within a database connection."""
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""Run migrations in 'online' mode with async support.
In this scenario we create an async Engine and associate
a connection with the context.
"""
# Get database URL and update config
url = get_database_url()
config.set_main_option("sqlalchemy.url", url)
# Create async engine
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool, # Don't pool connections for migrations
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
This function is called from storage.py's initialize() method via
anyio.to_thread.run_sync(), so it always runs in a worker thread
with its own event loop. We can safely use anyio.run() here.
"""
anyio.run(run_async_migrations)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -1,185 +0,0 @@
"""Initial schema for token storage database
This migration creates the initial database schema including:
- refresh_tokens: OAuth refresh tokens and user profiles
- audit_logs: Audit trail for security events
- oauth_clients: OAuth client credentials (DCR)
- oauth_sessions: OAuth flow session state (ADR-004 Progressive Consent)
- registered_webhooks: Webhook registration tracking (both OAuth and BasicAuth)
- schema_version: Legacy schema version tracking (deprecated, use alembic_version)
Revision ID: 001
Revises:
Create Date: 2025-12-17 22:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Create initial database schema."""
# Refresh tokens table (OAuth mode only, for background jobs)
op.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
token_audience TEXT DEFAULT 'nextcloud',
provisioned_at INTEGER,
provisioning_client_id TEXT,
scopes TEXT,
-- Browser session profile cache
user_profile TEXT,
profile_cached_at INTEGER
)
"""
)
# Audit logs table (both OAuth and BasicAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
)
"""
)
# Index on audit logs for efficient queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp
ON audit_logs(user_id, timestamp)
"""
)
# OAuth client credentials storage (OAuth mode only)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
# OAuth flow sessions (ADR-004 Progressive Consent)
op.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_sessions (
session_id TEXT PRIMARY KEY,
client_id TEXT,
client_redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
mcp_authorization_code TEXT UNIQUE,
idp_access_token TEXT,
idp_refresh_token TEXT,
user_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid',
requested_scopes TEXT,
granted_scopes TEXT,
is_provisioning BOOLEAN DEFAULT FALSE
)
"""
)
# Index for MCP authorization code lookups
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code
ON oauth_sessions(mcp_authorization_code)
"""
)
# Legacy schema version tracking table
# NOTE: This is deprecated in favor of Alembic's alembic_version table
# Kept for backward compatibility with pre-Alembic databases
op.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
"""
)
# Registered webhooks tracking (both BasicAuth and OAuth modes)
op.execute(
"""
CREATE TABLE IF NOT EXISTS registered_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL UNIQUE,
preset_id TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
# Indexes for efficient webhook queries
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_preset
ON registered_webhooks(preset_id)
"""
)
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_webhooks_created
ON registered_webhooks(created_at)
"""
)
def downgrade() -> None:
"""Drop all tables and indexes.
WARNING: This will destroy all data in the database!
Use with extreme caution.
"""
# Drop indexes first
op.execute("DROP INDEX IF EXISTS idx_webhooks_created")
op.execute("DROP INDEX IF EXISTS idx_webhooks_preset")
op.execute("DROP INDEX IF EXISTS idx_oauth_sessions_mcp_code")
op.execute("DROP INDEX IF EXISTS idx_audit_user_timestamp")
# Drop tables
op.execute("DROP TABLE IF EXISTS registered_webhooks")
op.execute("DROP TABLE IF EXISTS schema_version")
op.execute("DROP TABLE IF EXISTS oauth_sessions")
op.execute("DROP TABLE IF EXISTS oauth_clients")
op.execute("DROP TABLE IF EXISTS audit_logs")
op.execute("DROP TABLE IF EXISTS refresh_tokens")
-6
View File
@@ -1,6 +0,0 @@
"""Management API for Nextcloud MCP Server.
Provides REST endpoints for the Nextcloud PHP app to query server status,
user sessions, and vector sync metrics. All endpoints use OAuth bearer token
authentication via the UnifiedTokenVerifier.
"""
File diff suppressed because it is too large Load Diff
+14 -317
View File
@@ -5,7 +5,7 @@ from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from contextvars import ContextVar
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional, cast
from typing import TYPE_CHECKING, Optional
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
@@ -19,7 +19,6 @@ import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -676,29 +675,6 @@ async def setup_oauth_config():
logger.info(f"OIDC_JWKS_URI override: {jwks_uri}{jwks_uri_override}")
jwks_uri = jwks_uri_override
# Rewrite discovered endpoint URLs from public issuer to internal host
# This is needed when OIDC discovery returns public URLs (e.g., http://localhost:8080)
# but the server needs to access them via internal docker network (e.g., http://app:80)
from urllib.parse import urlparse
issuer_parsed = urlparse(issuer)
nextcloud_parsed = urlparse(nextcloud_host)
issuer_base = f"{issuer_parsed.scheme}://{issuer_parsed.netloc}"
nextcloud_base = f"{nextcloud_parsed.scheme}://{nextcloud_parsed.netloc}"
if issuer_base != nextcloud_base:
logger.info(f"Rewriting OIDC endpoints: {issuer_base}{nextcloud_base}")
def rewrite_url(url: str | None) -> str | None:
if url and url.startswith(issuer_base):
return url.replace(issuer_base, nextcloud_base, 1)
return url
userinfo_uri = rewrite_url(userinfo_uri) or userinfo_uri
jwks_uri = rewrite_url(jwks_uri)
introspection_uri = rewrite_url(introspection_uri)
registration_endpoint = rewrite_url(registration_endpoint)
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
@@ -710,6 +686,8 @@ async def setup_oauth_config():
# Auto-detect provider mode based on issuer
# External IdP mode: issuer doesn't match Nextcloud host
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
from urllib.parse import urlparse
def normalize_url(url: str) -> str:
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
parsed = urlparse(url)
@@ -725,16 +703,7 @@ async def setup_oauth_config():
issuer_normalized = normalize_url(issuer)
nextcloud_normalized = normalize_url(nextcloud_host)
# Use NEXTCLOUD_PUBLIC_ISSUER_URL for IdP detection when set
# This handles the case where MCP server accesses Nextcloud via internal URL (http://app:80)
# but the issuer in OIDC discovery is the public URL (http://localhost:8080)
public_issuer_for_detection = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer_for_detection:
comparison_issuer = normalize_url(public_issuer_for_detection)
else:
comparison_issuer = nextcloud_normalized
is_external_idp = not issuer_normalized.startswith(comparison_issuer)
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
if is_external_idp:
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
@@ -746,28 +715,6 @@ async def setup_oauth_config():
oauth_provider = "nextcloud"
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
# For integrated mode, rewrite OIDC endpoints to use internal URL
# The discovery document returns external URLs (http://localhost:8080)
# but the MCP server needs internal URLs (http://app:80) for backend requests
if jwks_uri and not os.getenv("OIDC_JWKS_URI"):
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
logger.info(
f" Auto-rewriting JWKS URI for internal access: {jwks_uri}{internal_jwks_uri}"
)
jwks_uri = internal_jwks_uri
if introspection_uri and not os.getenv("OIDC_INTROSPECTION_URI"):
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
logger.info(
f" Auto-rewriting introspection URI for internal access: {introspection_uri}{internal_introspection_uri}"
)
introspection_uri = internal_introspection_uri
if userinfo_uri:
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
logger.info(
f" Auto-rewriting userinfo URI for internal access: {userinfo_uri}{internal_userinfo_uri}"
)
userinfo_uri = internal_userinfo_uri
# Check if offline access (refresh tokens) is enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
@@ -1069,11 +1016,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
lifespan=oauth_lifespan,
token_verifier=token_verifier,
auth=auth_settings,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
@@ -1082,26 +1024,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
@@ -1259,8 +1186,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# We need to find it in the mounted routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.oauth_context = oauth_context_dict
route.app.state.oauth_context = oauth_context_dict
logger.info(
"OAuth context shared with browser_app for session auth"
)
@@ -1281,27 +1207,18 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.storage = storage
route.app.state.storage = storage
logger.info(
"Storage shared with browser_app for webhook management"
)
break
# Start background vector sync tasks (ADR-007)
# Start background vector sync tasks for BasicAuth mode (ADR-007)
# Scanner runs at server-level (once), not per-session
import anyio as anyio_module
settings = get_settings()
# Check if vector sync is enabled and determine the mode
enable_offline_access_for_sync = os.getenv(
"ENABLE_OFFLINE_ACCESS", "false"
).lower() in ("true", "1", "yes")
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if settings.vector_sync_enabled and not oauth_enabled:
# BasicAuth mode - single user sync
if not oauth_enabled and settings.vector_sync_enabled:
logger.info("Starting background vector sync tasks for BasicAuth mode")
# Get username from environment
@@ -1350,11 +1267,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.document_send_stream = send_stream
browser_app.state.document_receive_stream = receive_stream
browser_app.state.shutdown_event = shutdown_event
browser_app.state.scanner_wake_event = scanner_wake_event
route.app.state.document_send_stream = send_stream
route.app.state.document_receive_stream = receive_stream
route.app.state.shutdown_event = shutdown_event
route.app.state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state shared with browser_app for /app")
break
@@ -1397,167 +1313,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
shutdown_event.set()
await client.close()
# TaskGroup automatically cancels all tasks on exit
elif (
settings.vector_sync_enabled
and oauth_enabled
and enable_offline_access_for_sync
and refresh_token_storage
and encryption_key
):
# OAuth mode with offline access - multi-user sync
logger.info("Starting background vector sync tasks for OAuth mode")
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.vector.oauth_sync import (
oauth_processor_task,
user_manager_task,
)
# Get OIDC discovery URL (same as used for OAuth setup)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
# Get client credentials from oauth_context (set by setup_oauth_config)
# This includes credentials from DCR if dynamic registration was used
# Use different variable names to avoid shadowing client_id/client_secret from outer scope
oauth_ctx = getattr(app.state, "oauth_context", {})
oauth_config = oauth_ctx.get("config", {})
sync_client_id = oauth_config.get("client_id")
sync_client_secret = oauth_config.get("client_secret")
if not sync_client_id or not sync_client_secret:
logger.error(
"Cannot start OAuth vector sync: client credentials not found in oauth_context"
)
raise ValueError("OAuth client credentials required for vector sync")
# Create token broker for background operations
# Note: storage handles encryption internally, no key needed here
# Client credentials are needed for token refresh operations
token_broker = TokenBrokerService(
storage=refresh_token_storage,
oidc_discovery_url=discovery_url,
nextcloud_host=nextcloud_host,
client_id=sync_client_id,
client_secret=sync_client_secret,
)
# Store token broker in oauth_context for management API (revoke endpoint)
if hasattr(app.state, "oauth_context"):
app.state.oauth_context["token_broker"] = token_broker
logger.info("Token broker added to oauth_context for management API")
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
# User state tracking for user manager
user_states: dict = {}
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
app.state.document_receive_stream = receive_stream
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also store in module singleton for FastMCP session lifespans
_vector_sync_state.document_send_stream = send_stream
_vector_sync_state.document_receive_stream = receive_stream
_vector_sync_state.shutdown_event = shutdown_event
_vector_sync_state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state stored in module singleton")
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
browser_app = cast(Starlette, route.app)
browser_app.state.document_send_stream = send_stream
browser_app.state.document_receive_stream = receive_stream
browser_app.state.shutdown_event = shutdown_event
browser_app.state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state shared with browser_app for /app")
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
# Start user manager task (supervises per-user scanners)
await tg.start(
user_manager_task,
send_stream,
shutdown_event,
scanner_wake_event,
token_broker,
refresh_token_storage,
nextcloud_host,
user_states,
tg,
)
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
await tg.start(
oauth_processor_task,
i,
receive_stream.clone(),
shutdown_event,
token_broker,
nextcloud_host,
)
logger.info(
f"Background sync tasks started: 1 user manager + "
f"{settings.vector_sync_processor_workers} processors"
)
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
try:
yield
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
# Close token broker HTTP client
if token_broker._http_client:
await token_broker._http_client.aclose()
# TaskGroup automatically cancels all tasks on exit
else:
# No vector sync - just run MCP session manager
if settings.vector_sync_enabled:
# Log why vector sync is not starting
if oauth_enabled and not enable_offline_access_for_sync:
logger.warning(
"Vector sync enabled but ENABLE_OFFLINE_ACCESS=false - "
"vector sync requires offline access in OAuth mode"
)
elif oauth_enabled and not refresh_token_storage:
logger.warning(
"Vector sync enabled but refresh token storage not available"
)
elif oauth_enabled and not encryption_key:
logger.warning(
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
)
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
yield
@@ -1713,66 +1470,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
logger.info("Test webhook endpoint enabled: /webhooks/nextcloud")
# Add management API endpoints for Nextcloud PHP app (OAuth mode only)
if oauth_enabled:
from nextcloud_mcp_server.api.management import (
create_webhook,
delete_webhook,
get_chunk_context,
get_installed_apps,
get_server_status,
get_user_session,
get_vector_sync_status,
list_webhooks,
revoke_user_access,
unified_search,
vector_search,
)
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
routes.append(
Route(
"/api/v1/vector-sync/status",
get_vector_sync_status,
methods=["GET"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/session",
get_user_session,
methods=["GET"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/revoke",
revoke_user_access,
methods=["POST"],
)
)
routes.append(
Route("/api/v1/vector-viz/search", vector_search, methods=["POST"])
)
routes.append(
Route("/api/v1/chunk-context", get_chunk_context, methods=["GET"])
)
# ADR-018: Unified search endpoint for Nextcloud PHP app integration
routes.append(Route("/api/v1/search", unified_search, methods=["POST"]))
routes.append(Route("/api/v1/apps", get_installed_apps, methods=["GET"]))
# Webhook management endpoints
routes.append(Route("/api/v1/webhooks", list_webhooks, methods=["GET"]))
routes.append(Route("/api/v1/webhooks", create_webhook, methods=["POST"]))
routes.append(
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
)
logger.info(
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
"/api/v1/webhooks"
)
# ADR-016: Add Smithery well-known config endpoint for container runtime discovery
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
@@ -24,26 +24,6 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
logger = logging.getLogger(__name__)
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have secure flag.
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
Returns:
True if cookies should be secure (HTTPS), False otherwise
"""
# Explicit configuration takes precedence
explicit = os.getenv("COOKIE_SECURE", "").lower()
if explicit == "true":
return True
if explicit == "false":
return False
# Auto-detect from NEXTCLOUD_HOST protocol
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
return nextcloud_host.startswith("https://")
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"""Browser OAuth login endpoint - redirects to IdP for authentication.
@@ -70,10 +50,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
# Get redirect URL from query params (default to /app)
next_url = request.query_params.get("next", "/app")
logger.info(f"oauth_login - next_url: {next_url}")
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
@@ -95,7 +71,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
await storage.store_oauth_session(
session_id=state, # Use state as session ID
client_id="browser-ui",
client_redirect_uri=next_url, # Store the redirect URL for after auth
client_redirect_uri="/app",
state=state,
code_challenge=code_challenge,
code_challenge_method="S256",
@@ -109,11 +85,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
if not oauth_client.authorization_endpoint:
await oauth_client.discover()
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
nextcloud_resource_uri = oauth_config.get(
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
)
idp_params = {
"client_id": oauth_client.client_id,
"redirect_uri": callback_uri,
@@ -123,7 +94,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
@@ -161,11 +131,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
# Get Nextcloud resource URI for audience (background sync needs Nextcloud-scoped tokens)
nextcloud_resource_uri = oauth_config.get(
"nextcloud_resource_uri", oauth_config.get("nextcloud_host")
)
idp_params = {
"client_id": oauth_config["client_id"],
"redirect_uri": callback_uri,
@@ -175,7 +140,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"code_challenge": code_challenge,
"code_challenge_method": "S256",
"prompt": "consent", # Ensure refresh token
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
# Debug: Log full parameters
@@ -250,15 +214,12 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Retrieve code_verifier and redirect URL from session storage
# Retrieve code_verifier from session storage (PKCE required for all modes)
code_verifier = ""
next_url = "/app" # Default redirect
oauth_session = await storage.get_oauth_session(state)
if oauth_session:
# code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "")
# next_url was stored in client_redirect_uri field
next_url = oauth_session.get("client_redirect_uri", "/app")
# Clean up the temporary session
# Note: We don't have delete_oauth_session method, but it will expire after TTL
@@ -301,25 +262,6 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Rewrite token_endpoint from public URL to internal Docker URL
# Discovery document returns public URLs (e.g., http://localhost:8080/...)
# but server-side requests must use internal Docker network (e.g., http://app:80/...)
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
from urllib.parse import urlparse as parse_url
internal_host = oauth_config["nextcloud_host"]
internal_parsed = parse_url(internal_host)
token_parsed = parse_url(token_endpoint)
public_parsed = parse_url(public_issuer)
if token_parsed.hostname == public_parsed.hostname:
# Replace public URL with internal Docker URL
token_endpoint = f"{internal_parsed.scheme}://{internal_parsed.netloc}{token_parsed.path}"
logger.info(
f"Rewrote token endpoint to internal URL: {token_endpoint}"
)
token_params = {
"grant_type": "authorization_code",
"code": code,
@@ -396,35 +338,16 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
user_id = f"user-{secrets.token_hex(8)}"
username = "unknown"
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
)
# Extract granted scopes
granted_scopes = (
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Store refresh token (for background jobs ONLY)
if refresh_token:
logger.info(f"Storing refresh token for user_id: {user_id}")
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
logger.info(f" Granted scopes: {granted_scopes}")
logger.info(f" Expires at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=refresh_expires_at,
expires_at=None,
flow_type="browser", # Browser-based login flow
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
)
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
logger.info(
@@ -460,14 +383,13 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# Continue anyway - profile cache is optional for browser UI
# Create response and set session cookie
# Redirect to stored next_url (from OAuth session) or /app as default
response = RedirectResponse(next_url, status_code=302)
response = RedirectResponse("/app", status_code=302)
response.set_cookie(
key="mcp_session",
value=user_id,
max_age=86400 * 30, # 30 days
httponly=True,
secure=_should_use_secure_cookies(),
secure=False, # Set to True in production with HTTPS
samesite="lax",
)
+1 -12
View File
@@ -517,23 +517,12 @@ async def oauth_callback_nextcloud(request: Request):
token_data.get("scope", "").split() if token_data.get("scope") else None
)
# Calculate refresh token expiration from token response
refresh_expires_in = token_data.get("refresh_expires_in")
refresh_expires_at = None
if refresh_expires_in:
import time
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(f" refresh_expires_in: {refresh_expires_in}s")
logger.info(f" refresh_expires_at: {refresh_expires_at}")
logger.info("Storing refresh token:")
logger.info(f" user_id: {user_id}")
logger.info(" flow_type: flow2")
logger.info(" token_audience: nextcloud")
logger.info(f" provisioning_client_id: {state[:16]}...")
logger.info(f" scopes: {granted_scopes}")
logger.info(f" expires_at: {refresh_expires_at}")
await storage.store_refresh_token(
user_id=user_id,
@@ -542,7 +531,7 @@ async def oauth_callback_nextcloud(request: Request):
token_audience="nextcloud",
provisioning_client_id=state, # Store which client initiated provisioning
scopes=granted_scopes,
expires_at=refresh_expires_at,
expires_at=None, # Refresh tokens typically don't expire
)
logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}")
logger.info("=" * 60)
@@ -201,15 +201,8 @@ function vizApp() {
return `${baseUrl}/apps/calendar`;
case 'contact':
return `${baseUrl}/apps/contacts`;
case 'deck_card':
// URL pattern: /apps/deck/board/:boardId/card/:cardId
if (result.metadata && result.metadata.board_id) {
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
}
// Fallback if board_id not available
case 'deck':
return `${baseUrl}/apps/deck`;
case 'news_item':
return `${baseUrl}/apps/news/item/${result.id}`;
default:
return `${baseUrl}`;
}
+121 -64
View File
@@ -117,14 +117,7 @@ class RefreshTokenStorage:
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
"""
Initialize database schema using Alembic migrations.
This method handles three scenarios:
1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version
"""
"""Initialize database schema"""
if self._initialized:
return
@@ -132,59 +125,137 @@ class RefreshTokenStorage:
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Set restrictive permissions on database file if it exists
# Set restrictive permissions on database file
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
# Check database state and run appropriate migration strategy
async with aiosqlite.connect(self.db_path) as db:
# Check if database is managed by Alembic
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
await db.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
token_audience TEXT DEFAULT 'nextcloud', -- 'mcp-server' or 'nextcloud'
provisioned_at INTEGER, -- When Flow 2 was completed
provisioning_client_id TEXT, -- Which MCP client initiated Flow 1
scopes TEXT, -- JSON array of granted scopes
-- Browser session profile cache
user_profile TEXT, -- JSON cache of IdP user profile (for browser UI only)
profile_cached_at INTEGER -- When profile was last cached
)
"""
)
has_alembic = await cursor.fetchone() is not None
if not has_alembic:
# Check if this is a pre-Alembic database with existing schema
cursor = await db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='refresh_tokens'"
await db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
)
has_schema = await cursor.fetchone() is not None
"""
)
if has_schema:
logger.info(
f"Detected pre-Alembic database at {self.db_path}, "
"stamping with initial revision"
)
else:
logger.info(
f"Initializing new database at {self.db_path} with migrations"
)
# Create index on audit logs for efficient queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
"ON audit_logs(user_id, timestamp)"
)
# Run migrations in a worker thread using anyio.to_thread
# This allows Alembic to run its own async operations in a separate context
from anyio import to_thread
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
if not has_alembic:
if has_schema:
# Stamp existing database without running migrations
await to_thread.run_sync(stamp_database, self.db_path, "001")
logger.info(
"Pre-Alembic database stamped successfully. "
"Future schema changes will use migrations."
# OAuth client credentials storage
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
else:
# New database - run migrations
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database initialized with migrations")
else:
# Alembic-managed database - upgrade to latest
await to_thread.run_sync(upgrade_database, self.db_path, "head")
logger.info("Database upgraded to latest version")
"""
)
# Set restrictive permissions after initialization
# OAuth flow sessions (ADR-004 Progressive Consent)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_sessions (
session_id TEXT PRIMARY KEY,
client_id TEXT,
client_redirect_uri TEXT NOT NULL,
state TEXT,
code_challenge TEXT,
code_challenge_method TEXT,
mcp_authorization_code TEXT UNIQUE,
idp_access_token TEXT,
idp_refresh_token TEXT,
user_id TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
-- ADR-004 Progressive Consent fields
flow_type TEXT DEFAULT 'hybrid', -- 'hybrid', 'flow1', 'flow2'
requested_scopes TEXT, -- JSON array of requested scopes
granted_scopes TEXT, -- JSON array of granted scopes
is_provisioning BOOLEAN DEFAULT FALSE -- True if this is a Flow 2 provisioning session
)
"""
)
# Create index for MCP authorization code lookups
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_oauth_sessions_mcp_code "
"ON oauth_sessions(mcp_authorization_code)"
)
# Schema version tracking
await db.execute(
"""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at REAL NOT NULL
)
"""
)
# Registered webhooks tracking (both BasicAuth and OAuth modes)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS registered_webhooks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
webhook_id INTEGER NOT NULL UNIQUE,
preset_id TEXT NOT NULL,
created_at REAL NOT NULL
)
"""
)
# Create indexes for efficient webhook queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_preset "
"ON registered_webhooks(preset_id)"
)
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_webhooks_created "
"ON registered_webhooks(created_at)"
)
await db.commit()
# Set restrictive permissions after creation
os.chmod(self.db_path, 0o600)
self._initialized = True
@@ -216,8 +287,6 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
scopes_json = json.dumps(scopes) if scopes else None
@@ -363,9 +432,6 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
@@ -450,9 +516,6 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
@@ -624,9 +687,6 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
encrypted_reg_token = (
@@ -697,9 +757,6 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# Type narrowing: cipher is set after initialize()
assert self.cipher is not None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
@@ -65,12 +65,8 @@
<span>Contacts</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
<span>Deck Cards</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
<span>News</span>
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
<span>Deck</span>
</label>
</div>
</div>
+61 -191
View File
@@ -21,6 +21,7 @@ from typing import Dict, Optional, Tuple
import anyio
import httpx
import jwt
from cryptography.fernet import Fernet
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
@@ -103,8 +104,7 @@ class TokenBrokerService:
storage: RefreshTokenStorage,
oidc_discovery_url: str,
nextcloud_host: str,
client_id: str,
client_secret: str,
encryption_key: str,
cache_ttl: int = 300,
cache_early_refresh: int = 30,
):
@@ -112,25 +112,23 @@ class TokenBrokerService:
Initialize the Token Broker Service.
Args:
storage: Database storage for refresh tokens (handles encryption internally)
storage: Database storage for refresh tokens
oidc_discovery_url: OIDC provider discovery URL
nextcloud_host: Nextcloud server URL
client_id: OAuth client ID for token operations
client_secret: OAuth client secret for token operations
encryption_key: Fernet key for token encryption
cache_ttl: Cache TTL in seconds (default: 5 minutes)
cache_early_refresh: Early refresh threshold in seconds (default: 30 seconds)
"""
self.storage = storage
self.oidc_discovery_url = oidc_discovery_url
self.nextcloud_host = nextcloud_host
self.client_id = client_id
self.client_secret = client_secret
self.fernet = Fernet(
encryption_key.encode()
if isinstance(encryption_key, str)
else encryption_key
)
self.cache = TokenCache(cache_ttl, cache_early_refresh)
self._oidc_config = None
# Per-user locks for token refresh operations (prevents race conditions)
self._user_refresh_locks: dict[str, anyio.Lock] = {}
self._locks_lock = anyio.Lock() # Protects the locks dict itself
self._http_client = None
async def _get_http_client(self) -> httpx.AsyncClient:
@@ -141,24 +139,6 @@ class TokenBrokerService:
)
return self._http_client
async def _get_user_refresh_lock(self, user_id: str) -> anyio.Lock:
"""
Get or create a lock for a specific user's refresh operations.
This prevents race conditions when multiple concurrent requests
attempt to refresh the same user's token simultaneously.
Args:
user_id: User ID to get lock for
Returns:
anyio.Lock for this user's refresh operations
"""
async with self._locks_lock:
if user_id not in self._user_refresh_locks:
self._user_refresh_locks[user_id] = anyio.Lock()
return self._user_refresh_locks[user_id]
async def _get_oidc_config(self) -> dict:
"""Get OIDC configuration from discovery endpoint."""
if self._oidc_config is None:
@@ -168,37 +148,6 @@ 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.
@@ -231,8 +180,9 @@ class TokenBrokerService:
return None
try:
# storage.get_refresh_token() returns already-decrypted token
refresh_token = refresh_data["refresh_token"]
# Decrypt refresh token
encrypted_token = refresh_data["refresh_token"]
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
# Exchange refresh token for new access token
access_token, expires_in = await self._refresh_access_token(refresh_token)
@@ -321,79 +271,41 @@ class TokenBrokerService:
"""
# Check cache first (background tokens can be cached)
cache_key = f"{user_id}:background:{','.join(sorted(required_scopes))}"
refresh_in_progress_key = f"{user_id}:refresh_in_progress"
cached_token = await self.cache.get(cache_key)
if cached_token:
return cached_token
# Acquire per-user lock BEFORE refresh operation to prevent race conditions
refresh_lock = await self._get_user_refresh_lock(user_id)
async with refresh_lock:
# Double-check cache after acquiring lock
# (another thread may have refreshed while we waited)
cached_token = await self.cache.get(cache_key)
if cached_token:
logger.debug(
f"Token found in cache after lock acquisition for user {user_id}"
)
return cached_token
# Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.info(f"No refresh token found for user {user_id}")
return None
# Check if another thread is currently refreshing
if await self.cache.get(refresh_in_progress_key):
logger.debug(f"Refresh in progress for user {user_id}, waiting briefly")
await anyio.sleep(0.1) # Brief wait for in-progress refresh
# Check cache one more time after wait
cached_token = await self.cache.get(cache_key)
if cached_token:
logger.debug(
f"Token refreshed by another thread for user {user_id}"
)
return cached_token
try:
# Decrypt refresh token
encrypted_token = refresh_data["refresh_token"]
refresh_token = self.fernet.decrypt(encrypted_token.encode()).decode()
# Mark refresh as in-progress
await self.cache.set(refresh_in_progress_key, "true", expires_in=5)
# Get token with specific scopes for background operation
access_token, expires_in = await self._refresh_access_token_with_scopes(
refresh_token, required_scopes
)
try:
# Get stored refresh token
refresh_data = await self.storage.get_refresh_token(user_id)
if not refresh_data:
logger.info(f"No refresh token found for user {user_id}")
return None
# Cache the background token
await self.cache.set(cache_key, access_token, expires_in)
# storage.get_refresh_token() returns already-decrypted token
refresh_token = refresh_data["refresh_token"]
logger.info(
f"Generated background token for user {user_id} with scopes: {required_scopes}"
)
# Get token with specific scopes for background operation
# Pass user_id to enable refresh token rotation storage
access_token, expires_in = await self._refresh_access_token_with_scopes(
refresh_token, required_scopes, user_id=user_id
)
return access_token
# Cache the background token
await self.cache.set(cache_key, access_token, expires_in)
except Exception as e:
logger.error(f"Failed to get background token for user {user_id}: {e}")
await self.cache.invalidate(cache_key)
return None
logger.info(
f"Generated background token for user {user_id} with scopes: {required_scopes}"
)
return access_token
except Exception as e:
logger.error(
f"Failed to get background token for user {user_id}: {e}",
exc_info=True,
)
await self.cache.invalidate(cache_key)
return None
finally:
# Always clear the in-progress marker
await self.cache.invalidate(refresh_in_progress_key)
async def _refresh_access_token(
self, refresh_token: str, user_id: str | None = None
) -> Tuple[str, int]:
async def _refresh_access_token(self, refresh_token: str) -> Tuple[str, int]:
"""
Exchange refresh token for new access token.
@@ -401,24 +313,20 @@ class TokenBrokerService:
Args:
refresh_token: The refresh token
user_id: If provided, store the rotated refresh token for this user
Returns:
Tuple of (access_token, expires_in_seconds)
"""
config = await self._get_oidc_config()
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
token_endpoint = config["token_endpoint"]
client = await self._get_http_client()
# Request new access token using refresh token
# Include client credentials as required by most OAuth servers
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "openid profile email offline_access notes:read notes:write calendar:read calendar:write",
"client_id": self.client_id,
"client_secret": self.client_secret,
"scope": "openid profile email notes:read notes:write calendar:read calendar:write",
}
response = await client.post(
@@ -437,69 +345,42 @@ class TokenBrokerService:
access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
new_refresh_token = token_data.get("refresh_token")
if user_id and new_refresh_token and new_refresh_token != refresh_token:
# Calculate expiry as Unix timestamp (90 days from now)
expires_at = int(
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
)
await self.storage.store_refresh_token(
user_id=user_id,
refresh_token=new_refresh_token,
expires_at=expires_at,
)
logger.info(f"Stored rotated refresh token for user {user_id}")
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
# Validate audience
await self._validate_token_audience(access_token, "nextcloud")
logger.info(f"Refreshed access token (expires in {expires_in}s)")
return access_token, expires_in
async def _refresh_access_token_with_scopes(
self, refresh_token: str, required_scopes: list[str], user_id: str | None = None
self, refresh_token: str, required_scopes: list[str]
) -> Tuple[str, int]:
"""
Exchange refresh token for new access token with specific scopes.
This method implements scope downscoping for least privilege.
IMPORTANT: Nextcloud OIDC rotates refresh tokens on every use (one-time use).
When user_id is provided, this method stores the new refresh token returned
by Nextcloud to ensure subsequent refresh operations succeed.
Args:
refresh_token: The refresh token
required_scopes: Minimal scopes needed for this operation
user_id: If provided, store the rotated refresh token for this user
Returns:
Tuple of (access_token, expires_in_seconds)
"""
config = await self._get_oidc_config()
token_endpoint = self._rewrite_token_endpoint(config["token_endpoint"])
token_endpoint = config["token_endpoint"]
client = await self._get_http_client()
# Always include basic OpenID scopes + offline_access to get new refresh token
scopes = list(
set(["openid", "profile", "email", "offline_access"] + required_scopes)
)
# Always include basic OpenID scopes
scopes = list(set(["openid", "profile", "email"] + required_scopes))
# Request new access token with specific scopes
# Include client credentials as required by most OAuth servers
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": " ".join(scopes),
"client_id": self.client_id,
"client_secret": self.client_secret,
}
logger.info(
f"Token refresh request to {token_endpoint} with client_id={self.client_id[:16]}..."
)
response = await client.post(
token_endpoint,
data=data,
@@ -510,29 +391,14 @@ class TokenBrokerService:
logger.error(
f"Token refresh with scopes failed: {response.status_code} - {response.text}"
)
logger.error(f" client_id used: {self.client_id[:16]}...")
raise Exception(f"Token refresh failed: {response.status_code}")
token_data = response.json()
access_token = token_data["access_token"]
expires_in = token_data.get("expires_in", 3600) # Default 1 hour
# Handle refresh token rotation (Nextcloud OIDC rotates on every use)
new_refresh_token = token_data.get("refresh_token")
if user_id and new_refresh_token and new_refresh_token != refresh_token:
# Store the new refresh token for future use
# Calculate expiry as Unix timestamp (90 days from now)
expires_at = int(
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
)
await self.storage.store_refresh_token(
user_id=user_id,
refresh_token=new_refresh_token,
expires_at=expires_at,
)
logger.info(f"Stored rotated refresh token for user {user_id}")
# Note: Nextcloud validates token audience on API calls - no need to pre-validate here
# Validate audience
await self._validate_token_audience(access_token, "nextcloud")
logger.info(
f"Refreshed access token with scopes {scopes} (expires in {expires_in}s)"
@@ -587,8 +453,11 @@ class TokenBrokerService:
return False
try:
# storage.get_refresh_token() returns already-decrypted token
current_refresh_token = refresh_data["refresh_token"]
# Decrypt current refresh token
encrypted_token = refresh_data["refresh_token"]
current_refresh_token = self.fernet.decrypt(
encrypted_token.encode()
).decode()
# Get OIDC configuration
config = await self._get_oidc_config()
@@ -617,15 +486,13 @@ class TokenBrokerService:
new_refresh_token = token_data.get("refresh_token")
if new_refresh_token and new_refresh_token != current_refresh_token:
# storage.store_refresh_token() handles encryption internally
# Convert datetime to Unix timestamp (int) for database storage
expires_at = int(
(datetime.now(timezone.utc) + timedelta(days=90)).timestamp()
)
# Encrypt and store new refresh token
encrypted_new = self.fernet.encrypt(new_refresh_token.encode()).decode()
await self.storage.store_refresh_token(
user_id=user_id,
refresh_token=new_refresh_token,
expires_at=expires_at,
refresh_token=encrypted_new,
expires_at=datetime.now(timezone.utc)
+ timedelta(days=90), # 90-day expiry
)
logger.info(f"Rotated master refresh token for user {user_id}")
@@ -669,8 +536,11 @@ class TokenBrokerService:
refresh_data = await self.storage.get_refresh_token(user_id)
if refresh_data:
try:
# storage.get_refresh_token() returns already-decrypted token
refresh_token = refresh_data["refresh_token"]
# Attempt to revoke at IdP
encrypted_token = refresh_data["refresh_token"]
refresh_token = self.fernet.decrypt(
encrypted_token.encode()
).decode()
await self._revoke_token_at_idp(refresh_token)
except Exception as e:
logger.warning(f"Failed to revoke at IdP: {e}")
-2
View File
@@ -298,7 +298,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
"title": r.title,
"excerpt": r.excerpt,
"score": r.score,
"metadata": r.metadata,
}
for r in search_results
],
@@ -459,7 +458,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
), # Raw score from algorithm
"chunk_start_offset": r.chunk_start_offset,
"chunk_end_offset": r.chunk_end_offset,
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
}
for r in search_results
]
+1 -191
View File
@@ -253,195 +253,5 @@ def run(
)
@click.group()
def db():
"""Database migration management commands."""
pass
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database (can also use TOKEN_STORAGE_DB env var)",
)
@click.option(
"--revision",
"-r",
default="head",
show_default=True,
help="Target revision (default: head for latest)",
)
def upgrade(database_path: str, revision: str):
"""Upgrade database to a specific revision.
\b
Examples:
# Upgrade to latest version
$ nextcloud-mcp-server db upgrade
# Upgrade to specific revision
$ nextcloud-mcp-server db upgrade --revision 001
# Use custom database path
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
"""
from nextcloud_mcp_server.migrations import upgrade_database
try:
click.echo(f"Upgrading database to revision: {revision}")
upgrade_database(database_path, revision)
click.echo(click.style("✓ Database upgraded successfully", fg="green"))
except Exception as e:
click.echo(click.style(f"✗ Upgrade failed: {e}", fg="red"), err=True)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
@click.option(
"--revision",
"-r",
default="-1",
show_default=True,
help="Target revision (default: -1 for previous version)",
)
@click.confirmation_option(
prompt="Are you sure you want to downgrade the database? This may result in data loss."
)
def downgrade(database_path: str, revision: str):
"""Downgrade database to a specific revision.
WARNING: This may result in data loss! Use with caution.
\b
Examples:
# Downgrade by one version
$ nextcloud-mcp-server db downgrade
# Downgrade to specific revision
$ nextcloud-mcp-server db downgrade --revision 001
# Downgrade to base (empty database)
$ nextcloud-mcp-server db downgrade --revision base
"""
from nextcloud_mcp_server.migrations import downgrade_database
try:
click.echo(f"Downgrading database to revision: {revision}")
downgrade_database(database_path, revision)
click.echo(click.style("✓ Database downgraded successfully", fg="green"))
except Exception as e:
click.echo(click.style(f"✗ Downgrade failed: {e}", fg="red"), err=True)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
def current(database_path: str):
"""Show current database revision.
\b
Example:
$ nextcloud-mcp-server db current
"""
from nextcloud_mcp_server.migrations import get_current_revision
try:
revision = get_current_revision(database_path)
if revision:
click.echo(f"Current revision: {click.style(revision, fg='cyan')}")
else:
click.echo(
click.style(
"Database is not versioned (no alembic_version table)", fg="yellow"
)
)
except Exception as e:
click.echo(
click.style(f"✗ Failed to get current revision: {e}", fg="red"), err=True
)
raise click.ClickException(str(e))
@db.command()
@click.option(
"--database-path",
"-d",
envvar="TOKEN_STORAGE_DB",
default="/app/data/tokens.db",
show_default=True,
help="Path to token storage database",
)
def history(database_path: str):
"""Show migration history.
\b
Example:
$ nextcloud-mcp-server db history
"""
from nextcloud_mcp_server.migrations import show_migration_history
try:
click.echo("Migration history:")
show_migration_history(database_path)
except Exception as e:
click.echo(click.style(f"✗ Failed to show history: {e}", fg="red"), err=True)
raise click.ClickException(str(e))
@db.command()
@click.argument("message")
def migrate(message: str):
"""Create a new migration script (developers only).
The MESSAGE argument describes the changes in this migration.
\b
Examples:
$ nextcloud-mcp-server db migrate "add user preferences table"
$ nextcloud-mcp-server db migrate "add index on refresh_tokens.user_id"
Note: You must manually edit the generated migration file to add SQL statements.
"""
from nextcloud_mcp_server.migrations import create_migration
try:
click.echo(f"Creating new migration: {message}")
create_migration(message)
click.echo(click.style("✓ Migration created successfully", fg="green"))
click.echo(
"Edit the migration file in alembic/versions/ to add upgrade/downgrade SQL."
)
except Exception as e:
click.echo(
click.style(f"✗ Failed to create migration: {e}", fg="red"), err=True
)
raise click.ClickException(str(e))
# Create CLI group with subcommands
cli = click.Group()
cli.add_command(run)
cli.add_command(db)
if __name__ == "__main__":
cli()
run()
+3 -12
View File
@@ -228,10 +228,6 @@ class NewsClient(BaseNextcloudClient):
async def get_item(self, item_id: int) -> dict[str, Any]:
"""Get a specific item by ID.
Note: The News API doesn't have a direct single-item endpoint,
so we fetch all items and filter. For efficiency, consider
caching or using get_items with specific feed if known.
Args:
item_id: Item ID
@@ -239,15 +235,10 @@ class NewsClient(BaseNextcloudClient):
Item data
Raises:
ValueError: If item not found
HTTPStatusError: 404 if item not found
"""
# Fetch all items and find the one we need
# This is inefficient but the API doesn't provide a direct endpoint
items = await self.get_items(batch_size=-1, get_read=True)
for item in items:
if item.get("id") == item_id:
return item
raise ValueError(f"Item {item_id} not found")
response = await self._make_request("GET", f"{self.API_BASE}/items/{item_id}")
return response.json()
async def get_updated_items(
self,
-2
View File
@@ -1180,11 +1180,9 @@ class WebDAVClient(BaseNextcloudClient):
"name": display_name_elem.text,
"userVisible": user_visible_elem.text.lower() == "true"
if user_visible_elem is not None
and user_visible_elem.text is not None
else True,
"userAssignable": user_assignable_elem.text.lower() == "true"
if user_assignable_elem is not None
and user_assignable_elem.text is not None
else True,
}
logger.debug(f"Found tag '{tag_name}' with ID {tag_info['id']}")
-4
View File
@@ -205,7 +205,6 @@ class Settings:
vector_sync_scan_interval: int = 300 # seconds (5 minutes)
vector_sync_processor_workers: int = 3
vector_sync_queue_max_size: int = 10000
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
# Qdrant settings (mutually exclusive modes)
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
@@ -392,9 +391,6 @@ def get_settings() -> Settings:
vector_sync_queue_max_size=int(
os.getenv("VECTOR_SYNC_QUEUE_MAX_SIZE", "10000")
),
vector_sync_user_poll_interval=int(
os.getenv("VECTOR_SYNC_USER_POLL_INTERVAL", "60")
),
# Qdrant settings
qdrant_url=os.getenv("QDRANT_URL"),
qdrant_location=os.getenv("QDRANT_LOCATION"),
-192
View File
@@ -1,192 +0,0 @@
"""Database migration utilities for nextcloud-mcp-server.
This module provides helper functions for managing Alembic database migrations
programmatically. It enables automatic migration on application startup and
provides CLI integration.
"""
import logging
from pathlib import Path
from alembic.config import Config
from alembic import command
logger = logging.getLogger(__name__)
def get_alembic_config(database_path: str | Path | None = None) -> Config:
"""
Get Alembic configuration for programmatic use.
Works in both development and installed (Docker) modes by using
package location instead of alembic.ini file.
Args:
database_path: Path to SQLite database file. If None, uses default
(/app/data/tokens.db for Docker)
Returns:
Alembic Config object configured for the specified database
"""
from nextcloud_mcp_server import alembic as alembic_package
# Use package location (works in both editable and installed modes)
if alembic_package.__file__ is None:
raise RuntimeError("alembic package __file__ is None")
script_location = Path(alembic_package.__file__).parent
# Create config programmatically (no alembic.ini needed at runtime)
config = Config()
config.set_main_option("script_location", str(script_location))
config.set_main_option("path_separator", "os") # Suppress deprecation warning
# Set database URL
if database_path:
db_path = Path(database_path).resolve()
else:
db_path = Path("/app/data/tokens.db") # Default for Docker
url = f"sqlite+aiosqlite:///{db_path}"
config.set_main_option("sqlalchemy.url", url)
logger.debug(f"Alembic script location: {script_location}")
logger.debug(f"Database: {db_path}")
return config
def upgrade_database(
database_path: str | Path | None = None, revision: str = "head"
) -> None:
"""
Upgrade database to a specific revision.
Args:
database_path: Path to SQLite database file
revision: Target revision (default: "head" for latest)
"""
config = get_alembic_config(database_path)
logger.info(f"Upgrading database to revision: {revision}")
command.upgrade(config, revision)
logger.info("Database upgrade completed successfully")
def downgrade_database(
database_path: str | Path | None = None, revision: str = "-1"
) -> None:
"""
Downgrade database to a specific revision.
Args:
database_path: Path to SQLite database file
revision: Target revision (default: "-1" for previous version)
"""
config = get_alembic_config(database_path)
logger.warning(f"Downgrading database to revision: {revision}")
command.downgrade(config, revision)
logger.info("Database downgrade completed successfully")
def get_current_revision(database_path: str | Path | None = None) -> str | None:
"""
Get the current database revision by directly querying the alembic_version table.
Args:
database_path: Path to SQLite database file
Returns:
Current revision ID or None if not versioned
"""
import sqlite3
if database_path is None:
database_path = "/app/data/tokens.db"
db_path = Path(database_path).resolve()
if not db_path.exists():
logger.debug(f"Database does not exist: {db_path}")
return None
try:
# Query alembic_version table directly
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check if alembic_version table exists
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='alembic_version'"
)
has_table = cursor.fetchone() is not None
if not has_table:
conn.close()
return None
# Get current version
cursor.execute("SELECT version_num FROM alembic_version")
row = cursor.fetchone()
conn.close()
return row[0] if row else None
except Exception as e:
logger.error(f"Failed to get current revision: {e}")
return None
def stamp_database(
database_path: str | Path | None = None, revision: str = "head"
) -> None:
"""
Stamp database with a specific revision without running migrations.
This is useful for marking existing databases that were created before
Alembic was introduced. It tells Alembic "this database is at revision X"
without actually running the migration.
Args:
database_path: Path to SQLite database file
revision: Revision to stamp (default: "head" for latest)
"""
config = get_alembic_config(database_path)
logger.info(f"Stamping database with revision: {revision}")
command.stamp(config, revision)
logger.info("Database stamped successfully")
def show_migration_history(database_path: str | Path | None = None) -> None:
"""
Display migration history.
Args:
database_path: Path to SQLite database file
"""
config = get_alembic_config(database_path)
command.history(config, verbose=True)
def create_migration(message: str, autogenerate: bool = False) -> None:
"""
Create a new migration script.
Args:
message: Description of the migration
autogenerate: Whether to attempt auto-generation (requires SQLAlchemy models)
Note:
Since we don't use SQLAlchemy models, autogenerate will be disabled
and migrations must be written manually.
"""
config = get_alembic_config()
logger.info(f"Creating new migration: {message}")
if autogenerate:
logger.warning(
"Auto-generation is not supported (no SQLAlchemy models). "
"Migration will be created with empty upgrade/downgrade functions."
)
command.revision(config, message=message, autogenerate=False)
logger.info("Migration created successfully. Edit the file to add SQL statements.")
-3
View File
@@ -38,9 +38,6 @@ class SemanticSearchResult(BaseModel):
page_number: Optional[int] = Field(
default=None, description="Page number for PDF documents"
)
page_count: Optional[int] = Field(
default=None, description="Total number of pages in PDF document"
)
# Context expansion fields (optional, populated when include_context=True)
has_context_expansion: bool = Field(
default=False, description="Whether context expansion was performed"
@@ -53,11 +53,10 @@ def setup_tracing(
global _tracer
# Create resource with service name
pkg_name = __package__.split(".")[0] if __package__ else "nextcloud_mcp_server"
resource = Resource.create(
{
"service.name": service_name,
"service.version": version(pkg_name),
"service.version": version(__package__.split(".")[0]),
}
)
+1 -3
View File
@@ -111,7 +111,7 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
doc_types: set[str] = {
str(point.payload.get("doc_type"))
for point in scroll_results
if point.payload and point.payload.get("doc_type")
if point.payload.get("doc_type")
}
logger.debug(f"Found indexed document types for user {user_id}: {doc_types}")
@@ -138,7 +138,6 @@ class SearchResult:
chunk_start_offset: Character position where chunk starts (None if not available)
chunk_end_offset: Character position where chunk ends (None if not available)
page_number: Page number for PDF documents (None for other doc types)
page_count: Total number of pages in PDF document (None for other doc types)
chunk_index: Zero-based index of this chunk in the document
total_chunks: Total number of chunks in the document
point_id: Qdrant point ID for batch vector retrieval (None if not from Qdrant)
@@ -153,7 +152,6 @@ class SearchResult:
chunk_start_offset: int | None = None
chunk_end_offset: int | None = None
page_number: int | None = None
page_count: int | None = None
chunk_index: int = 0
total_chunks: int = 1
point_id: str | None = None
+5 -18
View File
@@ -219,22 +219,6 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
}
# Add file-specific metadata for PDF viewer
if doc_type == "file" and (path := result.payload.get("file_path")):
metadata["path"] = path
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
@@ -243,11 +227,14 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score, # Fusion score (RRF or DBSF)
metadata=metadata,
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
},
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
page_count=result.payload.get("page_count"),
chunk_index=result.payload.get("chunk_index", 0),
total_chunks=result.payload.get("total_chunks", 1),
point_id=str(result.id), # Qdrant point ID for batch retrieval
+2 -152
View File
@@ -209,64 +209,6 @@ async def _get_file_path_from_qdrant(
return None
async def _get_deck_metadata_from_qdrant(
user_id: str, card_id: int
) -> dict[str, int] | None:
"""Retrieve board_id and stack_id for a deck card from Qdrant payload.
Args:
user_id: User ID who owns the card
card_id: Card ID
Returns:
Dictionary with board_id and stack_id, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
# Query for any chunk of this card (we just need metadata)
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
limit=1,
with_payload=["board_id", "stack_id"],
with_vectors=False,
)
if scroll_result[0]:
point = scroll_result[0][0]
board_id = point.payload.get("board_id")
stack_id = point.payload.get("stack_id")
if board_id is not None and stack_id is not None:
logger.debug(
f"Retrieved deck metadata for card {card_id}: "
f"board_id={board_id}, stack_id={stack_id}"
)
return {"board_id": int(board_id), "stack_id": int(stack_id)}
logger.debug(
f"Could not find deck metadata in Qdrant for card {card_id} "
f"(might be legacy data without board_id/stack_id)"
)
return None
except Exception as e:
logger.debug(f"Error querying Qdrant for deck metadata: {e}")
return None
@dataclass
class ChunkContext:
"""Expanded chunk with surrounding context and position markers.
@@ -452,9 +394,7 @@ async def get_chunk_with_context(
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
# Fetch full document text
full_text = await _fetch_document_text(
nc_client, resolved_doc_id, doc_type, user_id
)
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
if full_text is None:
logger.warning(
f"Could not fetch document text for {doc_type} {doc_id}, "
@@ -513,7 +453,7 @@ async def get_chunk_with_context(
async def _fetch_document_text(
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
) -> str | None:
"""Fetch full text content of a document.
@@ -584,96 +524,6 @@ async def _fetch_document_text(
f"Error fetching file content for {doc_id}: {e}", exc_info=True
)
return None
elif doc_type == "news_item":
# Fetch news item by ID
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_id))
# Reconstruct full content as indexed: title + source + URL + body
# This ensures chunk offsets align with indexed content structure
body_markdown = html_to_markdown(item.get("body", ""))
item_title = item.get("title", "")
item_url = item.get("url", "")
feed_title = item.get("feedTitle", "")
content_parts = [item_title]
if feed_title:
content_parts.append(f"Source: {feed_title}")
if item_url:
content_parts.append(f"URL: {item_url}")
content_parts.append("") # Blank line
content_parts.append(body_markdown)
return "\n".join(content_parts)
elif doc_type == "deck_card":
# Fetch card from Deck API
# Try to get board_id/stack_id from Qdrant metadata (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
deck_metadata = await _get_deck_metadata_from_qdrant(user_id, int(doc_id))
if deck_metadata:
# Fast path: Direct lookup with known board_id/stack_id
board_id = deck_metadata["board_id"]
stack_id = deck_metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
)
logger.debug(
f"Retrieved deck card {doc_id} using metadata "
f"(board_id={board_id}, stack_id={stack_id})"
)
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, "
f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for board in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(
f"Skipping deleted board {board.id} while searching for card {doc_id}"
)
continue
stacks = await nc_client.deck.get_stacks(board.id)
for stack in stacks:
if card_found:
break
if stack.cards:
for c in stack.cards:
if c.id == int(doc_id):
card = c
card_found = True
logger.debug(
f"Found deck card {doc_id} in board {board.id}, "
f"stack {stack.id} (fallback iteration)"
)
break
if not card_found:
logger.warning(f"Deck card {doc_id} not found in any board/stack")
return None
# Type narrowing: card is set if we reach here
assert card is not None
# Reconstruct full content as indexed: title + "\n\n" + description
# This ensures chunk offsets align with indexed content structure
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
return "\n\n".join(content_parts)
else:
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
return None
+4 -17
View File
@@ -151,21 +151,6 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
}
# Add file-specific metadata for PDF viewer
if doc_type == "file" and (path := result.payload.get("file_path")):
metadata["path"] = path
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
@@ -174,11 +159,13 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score,
metadata=metadata,
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
},
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
page_count=result.payload.get("page_count"),
chunk_index=result.payload.get("chunk_index", 0),
total_chunks=result.payload.get("total_chunks", 1),
point_id=str(result.id), # Qdrant point ID for batch retrieval
+4 -6
View File
@@ -418,12 +418,11 @@ async def revoke_nextcloud_access(
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Get OAuth client credentials from storage
client_creds = await storage.get_oauth_client()
if not client_creds:
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
return RevocationResult(
success=False,
message="OAuth client credentials not found in storage.",
message="Token encryption key not configured.",
)
broker = TokenBrokerService(
@@ -433,8 +432,7 @@ async def revoke_nextcloud_access(
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
),
nextcloud_host=os.getenv("NEXTCLOUD_HOST"), # type: ignore
client_id=client_creds["client_id"],
client_secret=client_creds["client_secret"],
encryption_key=encryption_key,
)
# Revoke access
+3 -3
View File
@@ -65,13 +65,13 @@ def configure_semantic_tools(mcp: FastMCP):
database for optimal relevance. This provides the best of both semantic
understanding and keyword precision.
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
news items, and deck cards.
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
fully supported for indexing.
Args:
query: Natural language or keyword search query
limit: Maximum number of results to return (default: 10)
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
score_threshold: Minimum fusion score (0-1, default: 0.0)
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
RRF: Good general-purpose fusion using reciprocal ranks
-352
View File
@@ -1,352 +0,0 @@
"""OAuth mode vector sync orchestration.
Manages multi-user background vector sync when running in OAuth mode
with ENABLE_OFFLINE_ACCESS=true:
- User Manager: Monitors RefreshTokenStorage for user changes
- Per-User Scanners: One scanner task per provisioned user
- Shared Processor Pool: Processes documents from all users
"""
import logging
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
import anyio
from anyio.abc import TaskGroup, TaskStatus
from anyio.streams.memory import (
MemoryObjectReceiveStream,
MemoryObjectSendStream,
)
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
logger = logging.getLogger(__name__)
# Scopes required for vector sync operations
VECTOR_SYNC_SCOPES = [
"notes:read",
"files:read",
"deck:read",
# "news:read", # News app may not be installed
]
class NotProvisionedError(Exception):
"""User has not provisioned offline access or has revoked it."""
pass
@dataclass
class UserSyncState:
"""State for a single user's scanner task."""
user_id: str
cancel_scope: anyio.CancelScope
started_at: float = field(default_factory=time.time)
async def get_user_client(
user_id: str,
token_broker: "TokenBrokerService",
nextcloud_host: str,
) -> NextcloudClient:
"""Get an authenticated NextcloudClient for a user.
Args:
user_id: User identifier
token_broker: Token broker for obtaining access tokens
nextcloud_host: Nextcloud base URL
Returns:
Authenticated NextcloudClient
Raises:
NotProvisionedError: If user has not provisioned offline access
"""
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
if not token:
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=token,
username=user_id,
)
async def user_scanner_task(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
nextcloud_host: str,
*,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Scanner task for a single user in OAuth mode.
Gets a fresh token at the start of each scan cycle.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
shutdown_event: Event signaling shutdown
wake_event: Event to trigger immediate scan
token_broker: Token broker for obtaining access tokens
nextcloud_host: Nextcloud base URL
task_status: Status object for signaling task readiness
"""
logger.info(f"[OAuth] Scanner started for user: {user_id}")
settings = get_settings()
task_status.started()
while not shutdown_event.is_set():
nc_client = None
try:
# Get fresh token for this scan cycle
nc_client = await get_user_client(user_id, token_broker, nextcloud_host)
# Scan user's documents
await scan_user_documents(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
)
except NotProvisionedError:
logger.warning(
f"[OAuth] User {user_id} no longer provisioned, stopping scanner"
)
break
except Exception as e:
logger.error(f"[OAuth] Scanner error for {user_id}: {e}", exc_info=True)
finally:
if nc_client:
await nc_client.close()
# Sleep until next interval or wake event
try:
with anyio.move_on_after(settings.vector_sync_scan_interval):
await wake_event.wait()
except anyio.get_cancelled_exc_class():
break
logger.info(f"[OAuth] Scanner stopped for user: {user_id}")
async def oauth_processor_task(
worker_id: int,
receive_stream: MemoryObjectReceiveStream[DocumentTask],
shutdown_event: anyio.Event,
token_broker: "TokenBrokerService",
nextcloud_host: str,
*,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Processor task for OAuth mode.
Handles documents from any user by fetching tokens on-demand.
Args:
worker_id: Worker identifier for logging
receive_stream: Stream to receive documents from
shutdown_event: Event signaling shutdown
token_broker: Token broker for obtaining access tokens
nextcloud_host: Nextcloud base URL
task_status: Status object for signaling task readiness
"""
from nextcloud_mcp_server.vector.processor import process_document
logger.info(f"[OAuth] Processor {worker_id} started")
task_status.started()
while not shutdown_event.is_set():
doc_task = None
nc_client = None
try:
# Get document with timeout
with anyio.fail_after(1.0):
doc_task = await receive_stream.receive()
# Get token for THIS document's user
nc_client = await get_user_client(
doc_task.user_id, token_broker, nextcloud_host
)
# Process the document
await process_document(doc_task, nc_client)
except TimeoutError:
continue
except anyio.EndOfStream:
logger.info(f"[OAuth] Processor {worker_id}: Stream closed, exiting")
break
except NotProvisionedError:
if doc_task:
logger.warning(
f"[OAuth] User {doc_task.user_id} not provisioned, "
f"skipping {doc_task.doc_type}_{doc_task.doc_id}"
)
continue
except Exception as e:
if doc_task:
logger.error(
f"[OAuth] Processor {worker_id} error processing "
f"{doc_task.doc_type}_{doc_task.doc_id}: {e}",
exc_info=True,
)
else:
logger.error(f"[OAuth] Processor {worker_id} error: {e}", exc_info=True)
finally:
if nc_client:
await nc_client.close()
logger.info(f"[OAuth] Processor {worker_id} stopped")
async def _run_user_scanner_with_scope(
user_id: str,
cancel_scope: anyio.CancelScope,
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
nextcloud_host: str,
user_states: dict[str, UserSyncState],
) -> None:
"""Wrapper to run scanner with cancellation scope.
Cleans up user state on exit.
"""
cloned_stream = send_stream.clone()
try:
with cancel_scope:
await user_scanner_task(
user_id=user_id,
send_stream=cloned_stream,
shutdown_event=shutdown_event,
wake_event=wake_event,
token_broker=token_broker,
nextcloud_host=nextcloud_host,
)
finally:
# Clean up on exit
if user_id in user_states:
del user_states[user_id]
await cloned_stream.aclose()
async def user_manager_task(
send_stream: MemoryObjectSendStream[DocumentTask],
shutdown_event: anyio.Event,
wake_event: anyio.Event,
token_broker: "TokenBrokerService",
refresh_token_storage: "RefreshTokenStorage",
nextcloud_host: str,
user_states: dict[str, UserSyncState],
tg: TaskGroup,
*,
task_status: TaskStatus = anyio.TASK_STATUS_IGNORED,
) -> None:
"""Supervisor task that manages per-user scanners.
Periodically polls RefreshTokenStorage to detect:
- New users who have provisioned offline access -> start scanner
- Users who have revoked access -> cancel their scanner
Args:
send_stream: Stream to send documents to processors
shutdown_event: Event signaling shutdown
wake_event: Event to wake scanners for immediate scan
token_broker: Token broker for obtaining access tokens
refresh_token_storage: Storage for refresh tokens
nextcloud_host: Nextcloud base URL
user_states: Shared dict tracking active user scanners
tg: Task group for spawning scanner tasks
task_status: Status object for signaling task readiness
"""
settings = get_settings()
poll_interval = settings.vector_sync_user_poll_interval
logger.info(f"[OAuth] User manager started (poll interval: {poll_interval}s)")
task_status.started()
while not shutdown_event.is_set():
try:
# Get current provisioned users
provisioned_users = set(await refresh_token_storage.get_all_user_ids())
active_users = set(user_states.keys())
# Start scanners for new users
new_users = provisioned_users - active_users
for user_id in new_users:
logger.info(
f"[OAuth] Starting scanner for newly provisioned user: {user_id}"
)
cancel_scope = anyio.CancelScope()
user_states[user_id] = UserSyncState(
user_id=user_id,
cancel_scope=cancel_scope,
)
# Start scanner in task group
tg.start_soon(
_run_user_scanner_with_scope,
user_id,
cancel_scope,
send_stream,
shutdown_event,
wake_event,
token_broker,
nextcloud_host,
user_states,
)
# Cancel scanners for revoked users
revoked_users = active_users - provisioned_users
for user_id in revoked_users:
logger.info(f"[OAuth] Stopping scanner for revoked user: {user_id}")
state = user_states.get(user_id)
if state:
state.cancel_scope.cancel()
# Note: state will be removed by _run_user_scanner_with_scope on exit
if new_users:
logger.info(f"[OAuth] Started {len(new_users)} new scanner(s)")
if revoked_users:
logger.info(f"[OAuth] Stopped {len(revoked_users)} scanner(s)")
except Exception as e:
logger.error(f"[OAuth] User manager error: {e}", exc_info=True)
# Sleep until next poll
try:
with anyio.move_on_after(poll_interval):
await shutdown_event.wait()
except anyio.get_cancelled_exc_class():
break
# Cancel all remaining scanners on shutdown
logger.info(
f"[OAuth] User manager shutting down, cancelling {len(user_states)} scanner(s)"
)
for state in list(user_states.values()):
state.cancel_scope.cancel()
logger.info("[OAuth] User manager stopped")
+5 -121
View File
@@ -6,7 +6,6 @@ Processes documents from stream: fetches content, generates embeddings, stores i
import logging
import time
import uuid
from typing import Any, cast
import anyio
from anyio.abc import TaskStatus
@@ -312,102 +311,6 @@ async def _index_document(
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "deck_card":
# Fetch card from Deck API
# Use metadata from scanner if available (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
board = None
stack = None
if (
doc_task.metadata
and "board_id" in doc_task.metadata
and "stack_id" in doc_task.metadata
):
# Fast path: Direct lookup with known board_id/stack_id
board_id = doc_task.metadata["board_id"]
stack_id = doc_task.metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=int(board_id),
stack_id=int(stack_id),
card_id=int(doc_task.doc_id),
)
# Fetch board and stack info for metadata
boards = await nc_client.deck.get_boards()
for b in boards:
if b.id == int(board_id):
board = b
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if s.id == int(stack_id):
stack = s
break
break
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, stack_id={stack_id}, card_id={doc_task.doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for b in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if b.deletedAt > 0:
continue
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if card_found:
break
if s.cards:
for c in s.cards:
if c.id == int(doc_task.doc_id):
card = c
board = b
stack = s
card_found = True
break
if not card_found:
raise ValueError(
f"Deck card {doc_task.doc_id} not found in any board/stack"
)
# Type narrowing: card, board, stack are all set if we reach here
assert card is not None
assert board is not None
assert stack is not None
# Build content from card title and description
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
content = "\n\n".join(content_parts)
title = card.title
# Store deck-specific metadata
file_metadata = {
"board_id": board.id,
"board_title": board.title,
"stack_id": stack.id,
"stack_title": stack.title,
"card_type": card.type,
"duedate": (card.duedate.isoformat() if card.duedate else None),
"archived": card.archived,
"owner": (
card.owner.uid if hasattr(card.owner, "uid") else str(card.owner)
),
}
etag = card.etag or ""
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "file":
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
if not doc_task.file_path:
@@ -496,16 +399,14 @@ async def _index_document(
# Assign page numbers to chunks if page boundaries are available (PDFs)
page_boundaries = file_metadata.get("page_boundaries")
if doc_task.doc_type == "file" and page_boundaries is not None:
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
with trace_operation(
"vector_sync.assign_page_numbers",
attributes={
"vector_sync.chunk_count": len(chunks),
"vector_sync.page_count": len(page_boundaries_list),
"vector_sync.page_count": len(page_boundaries),
},
):
assign_page_numbers(chunks, page_boundaries_list)
assign_page_numbers(chunks, page_boundaries)
# Diagnostic: Verify page number assignment
assigned_count = sum(1 for c in chunks if c.page_number is not None)
@@ -528,8 +429,8 @@ async def _index_document(
f"Text length: {len(content)}, "
f"Chunks: {len(chunks)}, "
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
f"Page boundaries: {len(page_boundaries_list)} pages, "
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
f"Page boundaries: {len(page_boundaries)} pages, "
f"First boundary: {page_boundaries[0] if page_boundaries else 'None'}"
)
# Extract chunk texts for embedding
@@ -603,9 +504,6 @@ async def _index_document(
logger.warning("No page boundaries available, skipping highlighting")
return
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
logger.info(
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
)
@@ -616,7 +514,7 @@ async def _index_document(
lambda: PDFHighlighter.highlight_chunks_batch(
pdf_bytes=content_bytes,
chunks=chunk_data,
page_boundaries=page_boundaries_list,
page_boundaries=page_boundaries,
full_text=content,
color="yellow",
zoom=2.0,
@@ -725,20 +623,6 @@ async def _index_document(
if doc_task.doc_type == "news_item"
else {}
),
# Deck card-specific metadata
**(
{
"board_id": file_metadata.get("board_id"),
"board_title": file_metadata.get("board_title"),
"stack_id": file_metadata.get("stack_id"),
"stack_title": file_metadata.get("stack_title"),
"card_type": file_metadata.get("card_type"),
"duedate": file_metadata.get("duedate"),
"owner": file_metadata.get("owner"),
}
if doc_task.doc_type == "deck_card"
else {}
),
# Highlighted page image (PDF only)
**(
{
@@ -89,8 +89,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
if isinstance(vectors, dict):
actual_dimension = vectors["dense"].size
else:
# Type narrowing: vectors must be VectorParams if not dict
assert isinstance(vectors, VectorParams)
actual_dimension = vectors.size
# Validate dimension matches
+3 -221
View File
@@ -36,9 +36,6 @@ class DocumentTask:
operation: str # "index" or "delete"
modified_at: int
file_path: str | None = None # File path for files (when doc_id is file_id)
metadata: dict[str, int | str] | None = (
None # Additional metadata (e.g., board_id/stack_id for deck_card)
)
# Track documents potentially deleted (grace period before actual deletion)
@@ -82,11 +79,9 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
if scroll_result[0]:
timestamps = [
point.payload.get("indexed_at", 0)
for point in scroll_result[0]
if point.payload is not None
point.payload.get("indexed_at", 0) for point in scroll_result[0]
]
max_timestamp = max(timestamps) if timestamps else 0
max_timestamp = max(timestamps)
logger.info(
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
)
@@ -569,23 +564,9 @@ async def scan_user_documents(
except Exception as e:
logger.warning(f"Failed to scan news items for {user_id}: {e}")
# Scan Deck cards
deck_queued = 0
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += deck_queued
except Exception as e:
logger.warning(f"Failed to scan deck cards for {user_id}: {e}")
if queued > 0:
logger.info(
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
f"Sent {queued} documents ({file_queued} files, {news_queued} news items) for incremental sync: {user_id}"
)
else:
logger.debug(f"No changes detected for {user_id}")
@@ -772,202 +753,3 @@ async def scan_news_items(
_potentially_deleted[doc_key] = current_time
return queued
async def scan_deck_cards(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan user's Deck cards and queue changed cards for indexing.
Indexes cards from all non-archived boards and stacks.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of cards queued for processing
"""
settings = get_settings()
queued = 0
# Get indexed deck card IDs from Qdrant (for deletion tracking)
indexed_card_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
with_payload=["doc_id"],
with_vectors=False,
limit=10000,
)
indexed_card_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant")
# Fetch all boards
boards = await nc_client.deck.get_boards()
logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards")
card_count = 0
nextcloud_card_ids: set[str] = set()
# Iterate through boards
for board in boards:
# Skip archived boards
if board.archived:
continue
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}")
continue
# Get stacks for this board
stacks = await nc_client.deck.get_stacks(board.id)
# Iterate through stacks
for stack in stacks:
# Skip if stack has no cards
if not stack.cards:
continue
# Iterate through cards in stack
for card in stack.cards:
# Skip archived cards
if card.archived:
continue
card_count += 1
doc_id = str(card.id)
nextcloud_card_ids.add(doc_id)
# Use lastModified timestamp if available
modified_at = card.lastModified or 0
if initial_sync:
# Send everything on first sync - write placeholder first
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
else:
# Incremental sync: check if card exists and compare modified_at
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
f"Deck card {doc_id} reappeared, removing from deletion grace period"
)
del _potentially_deleted[doc_key]
# Query Qdrant for existing entry
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="deck_card", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
f"Found stale placeholder for deck card {doc_id} "
f"(age={placeholder_age:.1f}s), requeuing"
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
logger.info(
f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}"
)
record_vector_sync_scan(card_count)
# Check for deleted cards (not initial sync)
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_card_ids:
if doc_id not in nextcloud_card_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
f"Deck card {doc_id} missing for {time_missing:.1f}s "
f"(>{grace_period:.1f}s grace period), sending deletion"
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
f"Deck card {doc_id} missing for first time, starting grace period"
)
_potentially_deleted[doc_key] = current_time
return queued
@@ -1,190 +0,0 @@
"""Shared visualization utilities for PCA coordinate computation.
Extracts the PCA coordinate computation logic used by both:
- viz_routes.py (session-based auth)
- management.py (OAuth bearer token auth)
Both endpoints need to compute 3D PCA coordinates for search results,
so this module provides the shared implementation.
"""
import logging
from typing import Any
import anyio.to_thread
import numpy as np
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.pca import PCA
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
logger = logging.getLogger(__name__)
async def compute_pca_coordinates(
search_results: list[Any],
query_embedding: np.ndarray | list[float],
) -> dict[str, Any]:
"""Compute PCA 3D coordinates for search results visualization.
This is the shared implementation used by both viz_routes.py and
the management API. It retrieves vectors from Qdrant and applies
PCA dimensionality reduction.
Args:
search_results: List of SearchResult objects with point_id
query_embedding: The query embedding vector
Returns:
Dict with:
- coordinates_3d: List of [x, y, z] for each result
- query_coords: [x, y, z] for the query point
- pca_variance: Dict with pc1, pc2, pc3 explained variance ratios
"""
settings = get_settings()
# Collect point IDs from search results for batch retrieval
point_ids = [r.point_id for r in search_results if r.point_id]
if len(point_ids) < 2:
return {"coordinates_3d": [], "query_coords": []}
qdrant_client = await get_qdrant_client()
# Batch retrieve vectors from Qdrant
points_response = await qdrant_client.retrieve(
collection_name=settings.get_collection_name(),
ids=point_ids,
with_vectors=["dense"],
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
)
# Build chunk_vectors_map from batch response
chunk_vectors_map: dict[tuple[Any, Any, Any], Any] = {}
for point in points_response:
if point.vector is not None:
# Extract dense vector (handle both named and unnamed vectors)
if isinstance(point.vector, dict):
vector = point.vector.get("dense")
else:
vector = point.vector
if vector is not None and point.payload:
doc_id = point.payload.get("doc_id")
chunk_start = point.payload.get("chunk_start_offset")
chunk_end = point.payload.get("chunk_end_offset")
chunk_key = (doc_id, chunk_start, chunk_end)
chunk_vectors_map[chunk_key] = vector
if len(chunk_vectors_map) < 2:
return {"coordinates_3d": [], "query_coords": []}
# Detect embedding dimension
embedding_dim = None
for vector in chunk_vectors_map.values():
if vector is not None:
embedding_dim = len(vector)
break
if embedding_dim is None:
return {"coordinates_3d": [], "query_coords": []}
logger.info(f"Detected embedding dimension: {embedding_dim}")
# Build chunk vectors array in search_results order (1:1 mapping)
chunk_vectors = []
for result in search_results:
chunk_key = (result.id, result.chunk_start_offset, result.chunk_end_offset)
if chunk_key in chunk_vectors_map:
chunk_vectors.append(chunk_vectors_map[chunk_key])
else:
# Chunk not found in vectors (shouldn't happen)
logger.warning(
f"Chunk {chunk_key} not found in fetched vectors, using zero vector"
)
chunk_vectors.append(np.zeros(embedding_dim))
chunk_vectors = np.array(chunk_vectors)
# Ensure query_embedding is a numpy array
if not isinstance(query_embedding, np.ndarray):
query_embedding = np.array(query_embedding)
# Combine query vector with chunk vectors for PCA
# Query will be the last point in the array
all_vectors = np.vstack([chunk_vectors, np.array([query_embedding])])
# Normalize vectors to unit length (L2 normalization)
# This is critical because Qdrant uses COSINE distance, which only measures
# vector direction (angle), not magnitude. PCA uses Euclidean distance which
# considers both direction and magnitude. By normalizing to unit length,
# Euclidean distances in PCA space will match cosine distances.
norms = np.linalg.norm(all_vectors, axis=1, keepdims=True)
# Check for zero-norm vectors (can happen with empty/corrupted embeddings)
zero_norm_mask = norms[:, 0] < 1e-10
if zero_norm_mask.any():
zero_indices = np.where(zero_norm_mask)[0]
logger.warning(
f"Found {zero_norm_mask.sum()} zero-norm vectors at indices "
f"{zero_indices.tolist()}. Replacing with small epsilon to avoid "
"division by zero."
)
# Replace zero norms with small epsilon to avoid NaN
norms[zero_norm_mask] = 1e-10
all_vectors_normalized = all_vectors / norms
logger.info(
f"Normalized vectors: query_norm={norms[-1][0]:.3f}, "
f"doc_norm_range=[{norms[:-1].min():.3f}, {norms[:-1].max():.3f}]"
)
# Apply PCA dimensionality reduction (768-dim → 3D)
# Run in thread pool to avoid blocking the event loop (CPU-bound)
def _compute_pca(vectors: np.ndarray) -> tuple[np.ndarray, PCA]:
pca = PCA(n_components=3)
coords = pca.fit_transform(vectors)
return coords, pca
coords_3d, pca = await anyio.to_thread.run_sync(
lambda: _compute_pca(all_vectors_normalized)
)
# After fit, these attributes are guaranteed to be set
assert pca.explained_variance_ratio_ is not None
# Check for NaN values in PCA output (numerical instability)
nan_mask = np.isnan(coords_3d)
if nan_mask.any():
nan_rows = np.where(nan_mask.any(axis=1))[0]
logger.error(
f"Found NaN values in PCA output at {len(nan_rows)} points: "
f"{nan_rows.tolist()[:10]}. Replacing NaN with 0.0 to prevent "
"JSON serialization error."
)
# Replace NaN with 0 to allow JSON serialization
coords_3d = np.nan_to_num(coords_3d, nan=0.0)
# Split query coords from chunk coords
# Round to 2 decimal places for cleaner display
query_coords_3d = [round(float(x), 2) for x in coords_3d[-1]] # Last point is query
chunk_coords_3d = coords_3d[:-1] # All but last are chunks
logger.info(
f"PCA explained variance: PC1={pca.explained_variance_ratio_[0]:.3f}, "
f"PC2={pca.explained_variance_ratio_[1]:.3f}, "
f"PC3={pca.explained_variance_ratio_[2]:.3f}"
)
# Coordinates already match search_results order (1:1 mapping)
result_coords = [[round(float(x), 2) for x in coord] for coord in chunk_coords_3d]
return {
"coordinates_3d": result_coords,
"query_coords": query_coords_3d,
"pca_variance": {
"pc1": float(pca.explained_variance_ratio_[0]),
"pc2": float(pca.explained_variance_ratio_[1]),
"pc3": float(pca.explained_variance_ratio_[2]),
},
}
+6 -17
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.56.2"
version = "0.50.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.23,<1.24)",
"mcp[cli] (>=1.22,<1.23)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
@@ -20,7 +20,6 @@ dependencies = [
"caldav",
"pyjwt[crypto]>=2.8.0",
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
"alembic>=1.14.0", # Database migrations
"authlib>=1.6.5",
"qdrant-client>=1.7.0",
"fastembed>=0.7.3", # BM25 sparse vector embeddings for hybrid search
@@ -89,29 +88,19 @@ version_scheme = "pep440"
version_provider = "uv"
update_changelog_on_bump = true
major_version_zero = true
# MCP server version files + Helm appVersion
version_files = [
"charts/nextcloud-mcp-server/Chart.yaml:^appVersion:",
"charts/nextcloud-mcp-server/Chart.yaml:appVersion",
"charts/nextcloud-mcp-server/Chart.yaml:version"
]
# Ignore tags from other components
ignored_tag_formats = [
"nextcloud-mcp-server-*", # Helm chart tags
"astrolabe-v*", # Astrolabe tags
"nextcloud-mcp-server-*"
]
# Filter commits by scope (all scopes except helm and astrolabe)
[tool.commitizen.customize]
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm|astrolabe)\\))(\\([^)]+\\))?(!)?:\\s.+"
[tool.ruff.lint]
extend-select = ["I"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
[build-system]
requires = ["uv_build>=0.9.4,<0.10.0"]
@@ -138,7 +127,7 @@ dev = [
]
[project.scripts]
nextcloud-mcp-server = "nextcloud_mcp_server.cli:cli"
nextcloud-mcp-server = "nextcloud_mcp_server.cli:run"
smithery-main = "nextcloud_mcp_server.smithery_main:main"
[[tool.uv.index]]
-81
View File
@@ -1,81 +0,0 @@
#!/bin/bash
# Bump Astrolabe app version
set -euo pipefail
# Parse optional --increment flag
INCREMENT=""
while [[ $# -gt 0 ]]; do
case $1 in
--increment)
INCREMENT="$2"
shift 2
;;
*)
echo "❌ Error: Unknown option: $1" >&2
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
exit 1
;;
esac
done
# Validate dependencies
command -v uv >/dev/null 2>&1 || {
echo "❌ Error: uv not found" >&2
echo " Install from https://docs.astral.sh/uv/" >&2
exit 1
}
# Validate Astrolabe directory exists
if [ ! -d "third_party/astrolabe" ]; then
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
exit 1
fi
cd third_party/astrolabe
# Validate required files exist
if [ ! -f "appinfo/info.xml" ]; then
echo "❌ Error: appinfo/info.xml not found" >&2
exit 1
fi
if [ ! -f "package.json" ]; then
echo "❌ Error: package.json not found" >&2
exit 1
fi
echo "Bumping Astrolabe version..."
if [ -n "$INCREMENT" ]; then
echo " Forcing $INCREMENT bump"
fi
# Build commitizen command
CZ_CMD="uv run cz --config .cz.toml bump --yes"
if [ -n "$INCREMENT" ]; then
CZ_CMD="$CZ_CMD --increment $INCREMENT"
fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
cd ../..
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
echo "Common causes:" >&2
echo " - No commits with scope 'astrolabe' since last version" >&2
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
echo " - Git working directory not clean" >&2
exit 1
fi
echo "$output"
echo ""
echo "✓ Astrolabe version bumped successfully"
echo " Updated: appinfo/info.xml, package.json"
echo " Tag format: astrolabe-v\${version}"
echo ""
echo "Next steps:"
echo " cd ../.."
echo " git push --follow-tags"
cd ../..
-77
View File
@@ -1,77 +0,0 @@
#!/bin/bash
# Bump Helm chart version
set -euo pipefail
# Parse optional --increment flag
INCREMENT=""
while [[ $# -gt 0 ]]; do
case $1 in
--increment)
INCREMENT="$2"
shift 2
;;
*)
echo "❌ Error: Unknown option: $1" >&2
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
exit 1
;;
esac
done
# Validate dependencies
command -v uv >/dev/null 2>&1 || {
echo "❌ Error: uv not found" >&2
echo " Install from https://docs.astral.sh/uv/" >&2
exit 1
}
# Validate Helm chart directory exists
if [ ! -d "charts/nextcloud-mcp-server" ]; then
echo "❌ Error: Must run from repository root (charts/ not found)" >&2
exit 1
fi
cd charts/nextcloud-mcp-server
# Validate Chart.yaml exists
if [ ! -f "Chart.yaml" ]; then
echo "❌ Error: Chart.yaml not found" >&2
exit 1
fi
echo "Bumping Helm chart version..."
if [ -n "$INCREMENT" ]; then
echo " Forcing $INCREMENT bump"
fi
# Build commitizen command
CZ_CMD="uv run cz --config .cz.toml bump --yes"
if [ -n "$INCREMENT" ]; then
CZ_CMD="$CZ_CMD --increment $INCREMENT"
fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
cd ../..
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
echo "Common causes:" >&2
echo " - No commits with scope 'helm' since last version" >&2
echo " - No conventional commits found (use feat(helm):, fix(helm):, etc.)" >&2
echo " - Git working directory not clean" >&2
exit 1
fi
echo "$output"
echo ""
echo "✓ Helm chart version bumped successfully"
echo " Updated: Chart.yaml:version"
echo " Tag format: nextcloud-mcp-server-\${version}"
echo " Note: appVersion stays at MCP server version"
echo ""
echo "Next steps:"
echo " cd ../.."
echo " git push --follow-tags"
cd ../..
-64
View File
@@ -1,64 +0,0 @@
#!/bin/bash
# Bump MCP server version
set -euo pipefail
# Parse optional --increment flag
INCREMENT=""
while [[ $# -gt 0 ]]; do
case $1 in
--increment)
INCREMENT="$2"
shift 2
;;
*)
echo "❌ Error: Unknown option: $1" >&2
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
exit 1
;;
esac
done
# Validate dependencies
command -v uv >/dev/null 2>&1 || {
echo "❌ Error: uv not found" >&2
echo " Install from https://docs.astral.sh/uv/" >&2
exit 1
}
# Validate we're in the repository root
if [ ! -f "pyproject.toml" ]; then
echo "❌ Error: Must run from repository root (pyproject.toml not found)" >&2
exit 1
fi
echo "Bumping MCP server version..."
if [ -n "$INCREMENT" ]; then
echo " Forcing $INCREMENT bump"
fi
# Build commitizen command
CZ_CMD="uv run cz bump --yes"
if [ -n "$INCREMENT" ]; then
CZ_CMD="$CZ_CMD --increment $INCREMENT"
fi
# Run commitizen bump and capture output
if ! output=$($CZ_CMD 2>&1); then
echo "❌ Error: Version bump failed" >&2
echo "$output" >&2
echo "" >&2
echo "Common causes:" >&2
echo " - No commits since last version" >&2
echo " - No conventional commits found (use feat:, fix:, etc.)" >&2
echo " - Git working directory not clean" >&2
exit 1
fi
echo "$output"
echo ""
echo "✓ MCP server version bumped successfully"
echo " Updated: pyproject.toml, Chart.yaml:appVersion"
echo " Tag format: v\${version}"
echo ""
echo "Next steps:"
echo " git push --follow-tags"
-106
View File
@@ -1,106 +0,0 @@
#!/bin/bash
# Test commitizen scope filtering patterns
set -uo pipefail
echo "Testing commitizen scope filtering patterns..."
echo
# Regex patterns from configs
MCP_PATTERN='^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\((?:helm|astrolabe)\))(\([^)]+\))?(!)?:'
HELM_PATTERN='^(feat|fix|docs|refactor|perf|test|build|ci|chore)\(helm\)(!)?:'
ASTROLABE_PATTERN='^(feat|fix|docs|refactor|perf|test|build|ci|chore)\(astrolabe\)(!)?:'
test_pattern() {
local message="$1"
local pattern="$2"
# Use grep -P for Perl-compatible regex (supports negative lookahead)
if echo "$message" | grep -qP "$pattern"; then
return 0
else
return 1
fi
}
run_test() {
local message="$1"
local expected="$2"
local matched_components=()
# Check which components match
if test_pattern "$message" "$MCP_PATTERN"; then
matched_components+=("mcp")
fi
if test_pattern "$message" "$HELM_PATTERN"; then
matched_components+=("helm")
fi
if test_pattern "$message" "$ASTROLABE_PATTERN"; then
matched_components+=("astrolabe")
fi
# Convert array to space-separated string, or "none" if empty
local matched
if [ ${#matched_components[@]} -eq 0 ]; then
matched="none"
else
matched="${matched_components[*]}"
fi
# Validate expectation
if [ "$matched" = "$expected" ]; then
echo "✓ PASS: '$message'"
echo " → Matched: $matched"
return 0
else
echo "✗ FAIL: '$message'"
echo " → Matched: $matched (expected: $expected)"
return 1
fi
}
# Run all test cases
failed=0
passed=0
# MCP server commits (any scope except helm/astrolabe)
run_test "feat: add new feature" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "feat(mcp): add API endpoint" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "fix(mcp): resolve authentication bug" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "docs: update README" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "fix(ci): update workflow" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "feat(api): add endpoint" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "ci: configure GitHub Actions" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
# Helm chart commits
run_test "feat(helm): add resource limits" "helm" && passed=$((passed+1)) || failed=$((failed+1))
run_test "fix(helm): correct values schema" "helm" && passed=$((passed+1)) || failed=$((failed+1))
run_test "docs(helm): update deployment guide" "helm" && passed=$((passed+1)) || failed=$((failed+1))
# Astrolabe commits
run_test "feat(astrolabe): add dark mode" "astrolabe" && passed=$((passed+1)) || failed=$((failed+1))
run_test "fix(astrolabe): resolve UI bug" "astrolabe" && passed=$((passed+1)) || failed=$((failed+1))
run_test "perf(astrolabe): optimize rendering" "astrolabe" && passed=$((passed+1)) || failed=$((failed+1))
# Breaking changes
run_test "feat(mcp)!: breaking API change" "mcp" && passed=$((passed+1)) || failed=$((failed+1))
run_test "feat(helm)!: rename values" "helm" && passed=$((passed+1)) || failed=$((failed+1))
run_test "feat(astrolabe)!: remove deprecated feature" "astrolabe" && passed=$((passed+1)) || failed=$((failed+1))
# Edge cases
run_test "feat(invalid): test" "mcp" && passed=$((passed+1)) || failed=$((failed+1)) # Any scope except helm/astrolabe → MCP
run_test "random commit message" "none" && passed=$((passed+1)) || failed=$((failed+1)) # Not conventional commit
run_test "feat (mcp): space before scope" "none" && passed=$((passed+1)) || failed=$((failed+1)) # Invalid format
# Summary
echo
echo "=========================================="
echo "Results: $passed passed, $failed failed"
echo "=========================================="
if [ $failed -gt 0 ]; then
echo "❌ Some tests failed - scope patterns may need adjustment"
exit 1
else
echo "✅ All tests passed - scope patterns working correctly"
exit 0
fi
+7 -26
View File
@@ -310,16 +310,14 @@ async def test_news_api_get_items_unread_only(mocker):
async def test_news_api_get_item(mocker):
"""Test that get_item fetches all items and filters for the requested ID."""
# Create multiple items, only one should be returned
items = [
create_mock_news_item(item_id=100, title="Other Item 1"),
create_mock_news_item(item_id=123, title="Single Item"),
create_mock_news_item(item_id=200, title="Other Item 2"),
]
"""Test that get_item fetches a single item by ID."""
item = create_mock_news_item(item_id=123, title="Single Item")
mock_response = create_mock_response(status_code=200, json_data=item)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_get_items = mocker.patch.object(NewsClient, "get_items", return_value=items)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_item(item_id=123)
@@ -327,24 +325,7 @@ async def test_news_api_get_item(mocker):
assert result["id"] == 123
assert result["title"] == "Single Item"
# Verify it fetched all items with correct params
mock_get_items.assert_called_once_with(batch_size=-1, get_read=True)
async def test_news_api_get_item_not_found(mocker):
"""Test that get_item raises ValueError when item not found."""
items = [
create_mock_news_item(item_id=100, title="Item 1"),
create_mock_news_item(item_id=200, title="Item 2"),
]
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mocker.patch.object(NewsClient, "get_items", return_value=items)
client = NewsClient(mock_client, "testuser")
with pytest.raises(ValueError, match="Item 999 not found"):
await client.get_item(item_id=999)
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/items/123")
async def test_news_api_get_updated_items(mocker):
@@ -1,238 +0,0 @@
"""Integration tests for Deck card vector search.
These tests validate that Deck cards are properly indexed and searchable
via semantic search.
"""
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
async def test_deck_card_semantic_search(nc_mcp_client, nc_client, mocker):
"""Test that Deck cards can be indexed and searched via semantic search.
This test:
1. Creates a Deck board with a card
2. Manually triggers indexing (simulates vector sync)
3. Performs semantic search filtering by deck_card doc_type
4. Verifies the card is found in results
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board
board_title = "Test Board for Vector Search"
board = await nc_client.deck.create_board(title=board_title, color="ff0000")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Create a test card with searchable content
card_title = "Machine Learning Project Plan"
card_description = """
# ML Project Outline
## Phase 1: Data Collection
- Gather training data from multiple sources
- Clean and preprocess the dataset
## Phase 2: Model Training
- Experiment with different neural network architectures
- Use gradient descent optimization
## Phase 3: Deployment
- Deploy model to production environment
- Monitor performance metrics
"""
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Note: In a real integration test with vector sync enabled,
# we would wait for the background scanner to index the card.
# For now, we'll test the scanning function directly if needed.
# TODO: Once vector sync is running in test environment,
# add actual semantic search test here
# For now, just verify the card was created successfully
assert card.id is not None
assert card.title == card_title
assert card.description == card_description
# Test semantic search with deck_card filter
# Note: This will only work if vector sync is actually running
# and the card has been indexed
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": "machine learning neural networks",
"doc_types": ["deck_card"],
"limit": 10,
},
)
# If vector sync is working, we should find the card
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if our card is in the results
found_card = any(
r.get("doc_type") == "deck_card" and r.get("title") == card_title
for r in results
)
# Log result for debugging
if found_card:
print("✓ Successfully found Deck card in vector search")
else:
print(
"⚠ Deck card not found in search (may need time for indexing)"
)
except Exception as e:
# If search fails, it might be because indexing hasn't happened yet
print(f"⚠ Semantic search failed (indexing may not be complete): {e}")
finally:
# Cleanup: delete the board
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_appears_in_cross_app_search(nc_mcp_client, nc_client):
"""Test that Deck cards appear in cross-app semantic search (no doc_type filter).
This verifies that when searching without specifying doc_types,
Deck cards are included in the results alongside notes, files, etc.
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board with a distinctive card
board_title = "Cross-App Search Test Board"
board = await nc_client.deck.create_board(title=board_title, color="00ff00")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Use a very distinctive term to make it easy to find
unique_term = "xylophone_banana_unicorn_test"
_card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=f"Test Card with {unique_term}",
description=f"This card contains the unique search term: {unique_term}",
)
# Test cross-app search (no doc_type filter)
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": unique_term,
"limit": 20,
},
)
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if deck_card appears in cross-app results
deck_cards_found = [
r for r in results if r.get("doc_type") == "deck_card"
]
if deck_cards_found:
print(
f"✓ Found {len(deck_cards_found)} Deck card(s) in cross-app search"
)
else:
print(
"⚠ No Deck cards in cross-app search (may need time for indexing)"
)
except Exception as e:
print(f"⚠ Cross-app search failed: {e}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_chunk_context(nc_client):
"""Test that Deck card chunk context can be fetched for visualization.
This test validates that the vector viz UI can display Deck card previews
by fetching the chunk context via the context expansion module.
"""
from nextcloud_mcp_server.search.context import get_chunk_with_context
# Create board, stack, and card
board = await nc_client.deck.create_board(title="Test Board", color="ff0000")
try:
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
card_title = "Test Card for Context Expansion"
card_description = "This is a test description that should be fetched by the context expansion module when displaying chunk previews in the vector visualization UI."
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Fetch chunk context (simulates viz UI request)
# The chunk spans the title, so start=0 and end=len(card_title)
context = await get_chunk_with_context(
nc_client=nc_client,
user_id=nc_client.username,
doc_id=card.id,
doc_type="deck_card",
chunk_start=0,
chunk_end=len(card_title),
context_chars=100,
)
# Verify context was fetched successfully
assert context is not None, "Chunk context should not be None"
assert card_title in context.chunk_text, (
f"Card title '{card_title}' should be in chunk_text"
)
# Verify context includes description
assert card_description[:50] in context.after_context, (
"Card description should be in after_context"
)
print(f"✓ Successfully fetched chunk context for Deck card {card.id}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
@@ -1,77 +0,0 @@
"""Debug test to capture what's on the NC PHP app settings page."""
import logging
import os
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
async def test_capture_settings_page(browser):
"""Capture what's actually rendered on the personal settings page."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
context = await browser.new_context()
page = await context.new_page()
try:
# Login
logger.info(f"Logging in to {nextcloud_host} as {username}...")
await page.goto(f"{nextcloud_host}/login")
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_url(f"{nextcloud_host}/apps/dashboard/", timeout=10000)
logger.info("✓ Logged in")
# Navigate to settings
logger.info("Navigating to personal MCP settings...")
await page.goto(f"{nextcloud_host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
# Capture page content
page_content = await page.content()
# Save screenshot
screenshot_path = "/tmp/nc-php-app-settings-debug.png"
await page.screenshot(path=screenshot_path, full_page=True)
logger.info(f"Screenshot saved to: {screenshot_path}")
# Log what we found
logger.info(f"Page URL: {page.url}")
logger.info(f"Page title: {await page.title()}")
# Check for key strings
checks = [
"Authorize Access",
"Authorization Required",
"MCP Server",
"Sign In Again",
"astrolabe",
]
for check in checks:
found = check in page_content
logger.info(f" '{check}': {'FOUND' if found else 'NOT FOUND'}")
# Print first 500 chars of body
body = await page.locator("body").text_content()
logger.info(f"Body text (first 500 chars): {body[:500] if body else 'NO BODY'}")
# Try to find links
links = await page.locator("a").all_text_contents()
logger.info(f"Found {len(links)} links on page")
for i, link_text in enumerate(links[:10]):
logger.info(f" Link {i}: {link_text}")
# Check for error messages
if "error" in page_content.lower():
logger.warning("Page contains 'error' keyword")
finally:
await context.close()
-378
View File
@@ -1,378 +0,0 @@
"""Test OAuth authorization flow for Nextcloud PHP app (astrolabe).
Tests the complete PKCE OAuth flow from the NC PHP app perspective:
1. User navigates to personal settings
2. Clicks "Authorize Access" button
3. Completes OAuth authorization via Nextcloud OIDC app
4. Token is stored encrypted in Nextcloud database
5. App can use token to call MCP management API
This tests the architecture from ADR-018 where the NC PHP app uses
OAuth PKCE (public client) to obtain tokens from Nextcloud's OIDC app.
"""
import logging
import os
import httpx
import pytest
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
@pytest.fixture(scope="module")
def nextcloud_credentials():
"""Get Nextcloud credentials from environment."""
return {
"host": os.getenv("NEXTCLOUD_HOST", "http://localhost:8080"),
"username": os.getenv("NEXTCLOUD_USERNAME", "admin"),
"password": os.getenv("NEXTCLOUD_PASSWORD", "admin"),
}
@pytest.fixture(scope="module")
async def nc_admin_http_client(nextcloud_credentials):
"""HTTP client authenticated as admin user for NC API calls."""
async with httpx.AsyncClient(
base_url=nextcloud_credentials["host"],
auth=(nextcloud_credentials["username"], nextcloud_credentials["password"]),
timeout=30.0,
) as client:
yield client
@pytest.fixture(scope="module")
async def authorized_nc_session(browser, nextcloud_credentials):
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
This fixture:
1. Creates a browser context
2. Logs in to Nextcloud
3. Authorizes the MCP Server UI app (if not already authorized)
4. Returns the page for use in all tests
The authorization is done once and reused for all tests in this module.
"""
host = nextcloud_credentials["host"]
username = nextcloud_credentials["username"]
password = nextcloud_credentials["password"]
logger.info("Setting up module-scoped authorized NC session...")
# Create browser context that persists for module duration
context = await browser.new_context()
page = await context.new_page()
# Enable console message logging
page.on(
"console", lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}")
)
page.on("pageerror", lambda err: logger.error(f"Browser page error: {err}"))
try:
# Step 1: Login to Nextcloud
logger.info(f"Logging in to Nextcloud as {username}...")
await page.goto(f"{host}/login")
# Fill login form
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
# Wait for login to complete (dashboard loads)
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
logger.info("✓ Logged in successfully")
# Step 2: Navigate to personal MCP settings
logger.info("Navigating to personal MCP settings...")
await page.goto(f"{host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Step 3: Check if authorization is needed
if "Authorize Access" in page_content or "authorize" in page_content.lower():
logger.info("User not authorized yet - initiating OAuth flow...")
# Click "Authorize Access" button
authorize_selectors = [
'button:has-text("Authorize")',
'a:has-text("Authorize")',
'[href*="oauth/authorize"]',
'button:has-text("Connect")',
]
clicked = False
for selector in authorize_selectors:
try:
await page.click(selector, timeout=2000)
clicked = True
logger.info(f"✓ Clicked authorize button (selector: {selector})")
break
except Exception:
continue
if not clicked:
screenshot_path = "/tmp/nc-php-app-settings.png"
await page.screenshot(path=screenshot_path)
pytest.fail(
f"Could not find authorize button. Screenshot: {screenshot_path}"
)
# Wait for page to load after clicking
await page.wait_for_load_state("networkidle", timeout=10000)
current_url = page.url
# Handle OAuth consent if needed
if "/apps/oidc/authorize" in current_url:
logger.info("On OIDC authorization page - granting consent...")
consent_selectors = [
'button:has-text("Allow")',
'button:has-text("Authorize")',
'input[type="submit"][value="Allow"]',
'button[type="submit"]',
]
for selector in consent_selectors:
try:
await page.click(selector, timeout=2000)
logger.info(f"✓ Clicked consent button (selector: {selector})")
break
except Exception:
continue
# Wait for redirect back to settings
await page.wait_for_url(f"{host}/settings/user/mcp", timeout=15000)
await page.wait_for_load_state("networkidle")
logger.info("✓ OAuth authorization completed")
else:
logger.info("User already authorized")
# Return the page and context info for tests
yield {
"page": page,
"context": context,
"host": host,
"username": username,
}
finally:
# Cleanup at module end
logger.info("Closing authorized NC session...")
await context.close()
class TestNcPhpAppOAuth:
"""Test suite for NC PHP app OAuth integration."""
async def test_authorization_completed(self, authorized_nc_session):
"""Verify OAuth authorization was successful.
This test verifies the settings page shows the user is connected
after the module-scoped authorization fixture runs.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
# Navigate to settings (may already be there)
await page.goto(f"{host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Look for indicators that authorization succeeded
success_indicators = [
"Connected",
"Disconnect",
"Server Connection",
"Session Information",
"MCP Server",
]
has_success_indicator = any(
indicator in page_content for indicator in success_indicators
)
if not has_success_indicator:
screenshot_path = "/tmp/nc-php-app-auth-check.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Authorization check failed. Screenshot: {screenshot_path}")
assert has_success_indicator, "Settings page should show user is authorized"
logger.info("✓ Authorization verification passed")
async def test_token_storage_and_retrieval(self, authorized_nc_session):
"""Test that tokens are properly stored and can be retrieved.
Verifies the settings page displays session information,
indicating the token was stored and retrieved successfully.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Debug: take screenshot and log content excerpt
screenshot_path = "/tmp/nc-php-app-token-test.png"
await page.screenshot(path=screenshot_path)
logger.info(f"Screenshot saved: {screenshot_path}")
logger.info(f"Page content excerpt: {page_content[:1000]}")
# Verify session information is visible - these are the actual labels from template
session_indicators = [
"Server Connection",
"Session Information",
"Connection Management",
"MCP Server",
]
found_indicators = [ind for ind in session_indicators if ind in page_content]
assert len(found_indicators) >= 2, (
f"Expected session info on page. Found: {found_indicators}. Check {screenshot_path}"
)
logger.info(f"✓ Token retrieval verified - found: {found_indicators}")
async def test_management_api_access(
self, authorized_nc_session, nc_admin_http_client
):
"""Test that the NC PHP app can access MCP server management API.
Verifies the settings page successfully fetched data from the
MCP server's management API endpoints.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
# Check personal settings page shows server status
await page.goto(f"{host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Look for data that comes from management API or template structure
api_indicators = [
"Server Connection", # Section header
"Server URL", # Server info
"Connection Management", # Connection section
"Vector Visualization", # Vector sync section
]
found_api_data = [ind for ind in api_indicators if ind in page_content]
assert len(found_api_data) >= 1, (
f"Expected management API data on page. Found: {found_api_data}"
)
logger.info(f"✓ Management API access verified - found: {found_api_data}")
async def test_admin_settings_page(self, authorized_nc_session):
"""Test that admin settings page loads and displays server info.
The admin page should show server status from the management API.
"""
page = authorized_nc_session["page"]
host = authorized_nc_session["host"]
await page.goto(f"{host}/settings/admin/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Admin page should show server status
admin_indicators = [
"MCP Server",
"Server Status",
"Version",
]
found_indicators = [ind for ind in admin_indicators if ind in page_content]
# Admin page should at least show the MCP Server header
assert "MCP Server" in page_content or "mcp" in page_content.lower(), (
"Admin settings page should show MCP Server section"
)
logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
class TestNcPhpAppDisconnect:
"""Test suite for NC PHP app disconnect functionality.
Note: These tests are run separately and may modify the authorization state.
They should run after the main OAuth tests.
"""
@pytest.mark.skip(reason="Disconnect test modifies state - run manually if needed")
async def test_disconnect_flow(self, browser, nextcloud_credentials):
"""Test that users can disconnect (revoke) their authorization.
This test:
1. Logs in fresh (separate from authorized_nc_session)
2. Verifies user is authorized
3. Clicks "Disconnect" button
4. Verifies user is no longer authorized
Skipped by default as it modifies authorization state.
"""
host = nextcloud_credentials["host"]
username = nextcloud_credentials["username"]
password = nextcloud_credentials["password"]
context = await browser.new_context()
page = await context.new_page()
try:
# Login
await page.goto(f"{host}/login")
await page.fill('input[name="user"]', username)
await page.fill('input[name="password"]', password)
await page.click('button[type="submit"]')
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
# Navigate to personal settings
await page.goto(f"{host}/settings/user/mcp")
await page.wait_for_load_state("networkidle")
page_content = await page.content()
# Check if user is authorized
if "Disconnect" not in page_content:
pytest.skip("User not authorized - cannot test disconnect")
# Click disconnect button
disconnect_selectors = [
'button:has-text("Disconnect")',
'form[action*="disconnect"] button',
"#mcp-disconnect-button",
]
for selector in disconnect_selectors:
try:
# Handle confirmation dialog
page.on("dialog", lambda dialog: dialog.accept())
await page.click(selector, timeout=2000)
logger.info(f"✓ Clicked disconnect button (selector: {selector})")
break
except Exception:
continue
# Wait for page reload
await page.wait_for_load_state("networkidle")
# Verify we're back to "Authorize Access" state
page_content = await page.content()
assert "Authorize" in page_content, (
"Settings page should show 'Authorize Access' after disconnect"
)
logger.info("✓ Disconnect flow test passed")
finally:
await context.close()
-174
View File
@@ -1,174 +0,0 @@
"""
Test that DNS rebinding protection is properly disabled for containerized deployments.
This test verifies that the fix for MCP 1.23.x DNS rebinding protection works correctly.
Without the fix, requests with Host headers that don't match the default allowed list
(127.0.0.1:*, localhost:*, [::1]:*) would be rejected with a 421 Misdirected Request error.
"""
import httpx
import pytest
@pytest.mark.integration
async def test_accepts_various_host_headers():
"""Test that the MCP server accepts requests with various Host headers.
This test simulates what happens in containerized deployments where the Host
header might be a k8s service DNS name, a proxied hostname, or other values
that don't match the default allowed list.
Without the DNS rebinding protection fix, these requests would fail with:
- 421 Misdirected Request (for Host header mismatch)
- 403 Forbidden (for Origin header mismatch)
"""
mcp_url = "http://localhost:8000/mcp"
# Test various Host headers that would be rejected by DNS rebinding protection
test_cases = [
{
"name": "Kubernetes service DNS",
"headers": {
"Host": "nextcloud-mcp-server.default.svc.cluster.local:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Custom domain",
"headers": {
"Host": "mcp.example.com:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Proxied hostname",
"headers": {
"Host": "proxy.internal:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Default localhost (should always work)",
"headers": {
"Host": "localhost:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
]
# Create a simple initialize request payload
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
for test_case in test_cases:
print(f"\n🧪 Testing: {test_case['name']}")
print(f" Host header: {test_case['headers']['Host']}")
response = await client.post(
mcp_url,
json=initialize_request,
headers=test_case["headers"],
timeout=10.0,
)
# With DNS rebinding protection enabled (MCP 1.23 default), these would fail with:
# - 421 Misdirected Request (Host header not in allowed list)
# - 403 Forbidden (Origin header not in allowed list)
#
# With our fix (enable_dns_rebinding_protection=False), they should succeed
assert response.status_code in [200, 202], (
f"Request failed for {test_case['name']}: "
f"status={response.status_code}, "
f"headers={test_case['headers']}, "
f"body={response.text[:200]}"
)
print(f" ✅ Status: {response.status_code}")
# For SSE responses (status 200), verify we got SSE format
# For JSON responses (status 202), verify we got valid JSON
if response.status_code == 200:
# SSE response - should start with "event: message" or similar
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format for {test_case['name']}, got: {response_text[:200]}"
)
print(" ✅ Received SSE stream response")
elif response.status_code == 202:
# JSON response for notifications
response_json = response.json()
assert "jsonrpc" in response_json or response_json is None, (
f"Invalid response for {test_case['name']}: {response_json}"
)
print(" ✅ Received JSON response")
@pytest.mark.integration
async def test_dns_rebinding_protection_is_disabled():
"""Verify that DNS rebinding protection is actually disabled in the configuration.
This test makes a request that would DEFINITELY fail if DNS rebinding protection
was enabled with default settings (only allowing 127.0.0.1:*, localhost:*, [::1]:*).
"""
mcp_url = "http://localhost:8000/mcp"
# Use a Host header that would NEVER be in the default allowed list
malicious_host = "evil.attacker.com:8000"
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
response = await client.post(
mcp_url,
json=initialize_request,
headers={
"Host": malicious_host,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=10.0,
)
# If DNS rebinding protection was enabled, this would return:
# - 421 Misdirected Request (Host header validation failed)
#
# Since we disabled it, this should succeed (status 200 or 202)
assert response.status_code in [200, 202], (
f"DNS rebinding protection may still be enabled! "
f"Request with Host='{malicious_host}' was rejected: "
f"status={response.status_code}, body={response.text[:500]}"
)
# Verify we got a valid response (SSE or JSON)
if response.status_code == 200:
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format, got: {response_text[:200]}"
)
print("✅ DNS rebinding protection is properly disabled")
print(
f" Request with Host '{malicious_host}' succeeded: {response.status_code}"
)
+35 -152
View File
@@ -47,14 +47,13 @@ def mock_oidc_config():
@pytest.fixture
async def token_broker(mock_storage):
async def token_broker(mock_storage, encryption_key):
"""Create TokenBrokerService instance."""
broker = TokenBrokerService(
storage=mock_storage,
oidc_discovery_url="https://idp.example.com/.well-known/openid-configuration",
nextcloud_host="https://nextcloud.example.com",
client_id="test_client_id",
client_secret="test_client_secret",
encryption_key=encryption_key,
cache_ttl=300,
)
yield broker
@@ -144,12 +143,14 @@ class TestTokenBrokerService:
token_broker.storage.get_refresh_token.assert_not_called()
async def test_get_nextcloud_token_refresh(
self, token_broker, mock_storage, mock_oidc_config
self, token_broker, mock_storage, encryption_key, mock_oidc_config
):
"""Test getting token via refresh when not cached."""
# Storage returns already-decrypted refresh token (encryption handled by storage layer)
# Setup encrypted refresh token in storage
fernet = Fernet(encryption_key.encode())
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
mock_storage.get_refresh_token.return_value = {
"refresh_token": "test_refresh_token",
"refresh_token": encrypted_token,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
}
@@ -186,12 +187,14 @@ class TestTokenBrokerService:
assert token is None
async def test_refresh_master_token(
self, token_broker, mock_storage, mock_oidc_config
self, token_broker, mock_storage, encryption_key, mock_oidc_config
):
"""Test master refresh token rotation."""
# Storage returns already-decrypted refresh token
# Setup current refresh token
fernet = Fernet(encryption_key.encode())
encrypted_token = fernet.encrypt(b"current_refresh_token").decode()
mock_storage.get_refresh_token.return_value = {
"refresh_token": "current_refresh_token",
"refresh_token": encrypted_token,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
}
@@ -214,19 +217,25 @@ class TestTokenBrokerService:
success = await token_broker.refresh_master_token("user1")
assert success is True
# Verify new token was stored (storage handles encryption)
# Verify new token was stored
mock_storage.store_refresh_token.assert_called_once()
call_args = mock_storage.store_refresh_token.call_args[1]
assert call_args["user_id"] == "user1"
assert call_args["refresh_token"] == "new_refresh_token"
# Decrypt to verify it's the new token
stored_token = fernet.decrypt(
call_args["refresh_token"].encode()
).decode()
assert stored_token == "new_refresh_token"
async def test_refresh_master_token_no_rotation(
self, token_broker, mock_storage, mock_oidc_config
self, token_broker, mock_storage, encryption_key, mock_oidc_config
):
"""Test when IdP returns same refresh token (no rotation)."""
# Storage returns already-decrypted refresh token
# Setup current refresh token
fernet = Fernet(encryption_key.encode())
encrypted_token = fernet.encrypt(b"same_refresh_token").decode()
mock_storage.get_refresh_token.return_value = {
"refresh_token": "same_refresh_token",
"refresh_token": encrypted_token,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
}
@@ -252,12 +261,14 @@ class TestTokenBrokerService:
mock_storage.store_refresh_token.assert_not_called()
async def test_revoke_nextcloud_access(
self, token_broker, mock_storage, mock_oidc_config
self, token_broker, mock_storage, encryption_key, mock_oidc_config
):
"""Test revoking Nextcloud access."""
# Storage returns already-decrypted refresh token
# Setup refresh token for revocation
fernet = Fernet(encryption_key.encode())
encrypted_token = fernet.encrypt(b"token_to_revoke").decode()
mock_storage.get_refresh_token.return_value = {
"refresh_token": "token_to_revoke",
"refresh_token": encrypted_token,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
}
@@ -300,11 +311,15 @@ class TestTokenBrokerService:
with pytest.raises(ValueError, match="doesn't include wrong-audience"):
await token_broker._validate_token_audience(test_token, "wrong-audience")
async def test_token_refresh_with_network_error(self, token_broker, mock_storage):
async def test_token_refresh_with_network_error(
self, token_broker, mock_storage, encryption_key
):
"""Test handling network errors during token refresh."""
# Storage returns already-decrypted refresh token
# Setup encrypted refresh token
fernet = Fernet(encryption_key.encode())
encrypted_token = fernet.encrypt(b"test_refresh_token").decode()
mock_storage.get_refresh_token.return_value = {
"refresh_token": "test_refresh_token",
"refresh_token": encrypted_token,
"expires_at": datetime.now(timezone.utc) + timedelta(days=30),
}
@@ -336,135 +351,3 @@ class TestTokenBrokerService:
)
assert results == ["token1", "token2", "token1", "token2"]
class TestRefreshTokenRotation:
"""Test refresh token rotation handling.
Nextcloud OIDC rotates refresh tokens on every use (one-time use).
These tests verify that the token broker stores rotated tokens.
"""
async def test_refresh_access_token_stores_rotated_token(
self, mock_storage, mock_oidc_config
):
"""Verify that new refresh token is stored after successful refresh."""
broker = TokenBrokerService(
storage=mock_storage,
oidc_discovery_url="http://localhost:8080/.well-known/openid-configuration",
nextcloud_host="http://localhost:8080",
client_id="test_client_id",
client_secret="test_client_secret",
)
# Mock HTTP response with rotated refresh token
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token_abc",
"refresh_token": "new_refresh_token_456", # Rotated token
"expires_in": 900,
"token_type": "Bearer",
}
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
with patch.object(broker, "_get_oidc_config", return_value=mock_oidc_config):
with patch.object(
broker, "_get_http_client", return_value=mock_http_client
):
(
access_token,
expires_in,
) = await broker._refresh_access_token_with_scopes(
refresh_token="old_refresh_token_123",
required_scopes=["notes:read"],
user_id="admin",
)
assert access_token == "new_access_token_abc"
assert expires_in == 900
# CRITICAL: Verify the new refresh token was stored
mock_storage.store_refresh_token.assert_called_once()
call_args = mock_storage.store_refresh_token.call_args
assert call_args.kwargs["user_id"] == "admin"
assert call_args.kwargs["refresh_token"] == "new_refresh_token_456"
await broker.close()
async def test_no_storage_when_refresh_token_unchanged(
self, mock_storage, mock_oidc_config
):
"""Verify storage is NOT called when refresh token is unchanged."""
broker = TokenBrokerService(
storage=mock_storage,
oidc_discovery_url="http://localhost:8080/.well-known/openid-configuration",
nextcloud_host="http://localhost:8080",
client_id="test_client_id",
client_secret="test_client_secret",
)
# Response returns SAME refresh token (no rotation)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token_abc",
"refresh_token": "same_refresh_token", # Same as input
"expires_in": 900,
}
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
with patch.object(broker, "_get_oidc_config", return_value=mock_oidc_config):
with patch.object(
broker, "_get_http_client", return_value=mock_http_client
):
await broker._refresh_access_token_with_scopes(
refresh_token="same_refresh_token",
required_scopes=["notes:read"],
user_id="admin",
)
# Should NOT store since token didn't change
mock_storage.store_refresh_token.assert_not_called()
await broker.close()
async def test_no_storage_without_user_id(self, mock_storage, mock_oidc_config):
"""Verify storage is NOT called when user_id is None."""
broker = TokenBrokerService(
storage=mock_storage,
oidc_discovery_url="http://localhost:8080/.well-known/openid-configuration",
nextcloud_host="http://localhost:8080",
client_id="test_client_id",
client_secret="test_client_secret",
)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"access_token": "new_access_token_abc",
"refresh_token": "new_refresh_token_456",
"expires_in": 900,
}
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
with patch.object(broker, "_get_oidc_config", return_value=mock_oidc_config):
with patch.object(
broker, "_get_http_client", return_value=mock_http_client
):
await broker._refresh_access_token_with_scopes(
refresh_token="old_token",
required_scopes=["notes:read"],
user_id=None, # No user_id
)
# Should NOT store since no user_id
mock_storage.store_refresh_token.assert_not_called()
await broker.close()
-25
View File
@@ -1,25 +0,0 @@
[tool.commitizen]
name = "cz_conventional_commits"
version = "0.4.4"
tag_format = "astrolabe-v$version"
version_scheme = "semver"
update_changelog_on_bump = true
major_version_zero = true
# Update Astrolabe-specific files only
version_files = [
"appinfo/info.xml:<version>",
"package.json:version"
]
# Ignore tags from other components
ignored_tag_formats = [
"v*", # MCP server tags
"nextcloud-mcp-server-*", # Helm chart tags
]
# Filter commits by scope
[tool.commitizen.customize]
changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:"
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)\\(astrolabe\\)(!)?:\\s.+"
message_template = "{{change_type}}(astrolabe): {{message}}"
-9
View File
@@ -1,9 +0,0 @@
module.exports = {
extends: [
'@nextcloud',
],
rules: {
'jsdoc/require-jsdoc': 'off',
'vue/first-attribute-linebreak': 'off',
},
}
-50
View File
@@ -1,50 +0,0 @@
version: 2
updates:
- package-ecosystem: composer
directory: "/"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/cs-fixer"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/openapi-extractor"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/phpunit"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: composer
directory: "/vendor-bin/psalm"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
- package-ecosystem: npm
directory: "/"
schedule:
interval: weekly
day: saturday
time: "03:00"
timezone: Europe/Paris
open-pull-requests-limit: 10
@@ -1,36 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Block unconventional commits
on:
pull_request:
types: [opened, ready_for_review, reopened, synchronize]
permissions:
contents: read
concurrency:
group: block-unconventional-commits-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
block-unconventional-commits:
name: Block unconventional commits
runs-on: ubuntu-latest-low
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: webiny/action-conventional-commits@8bc41ff4e7d423d56fa4905f6ff79209a78776c7 # v1.3.0
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-36
View File
@@ -1,36 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Block fixup and squash commits
on:
pull_request:
types: [opened, ready_for_review, reopened, synchronize]
permissions:
contents: read
concurrency:
group: fixup-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
commit-message-check:
if: github.event.pull_request.draft == false
permissions:
pull-requests: write
name: Block fixup and squash commits
runs-on: ubuntu-latest-low
steps:
- name: Run check
uses: skjnldsv/block-fixup-merge-action@c138ea99e45e186567b64cf065ce90f7158c236a # v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
-100
View File
@@ -1,100 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint eslint
on: pull_request
permissions:
contents: read
concurrency:
group: lint-eslint-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest-low
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'appinfo/info.xml'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- '.eslintrc.*'
- '.eslintignore'
- '**.js'
- '**.ts'
- '**.vue'
lint:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: NPM lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
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
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: [changes, lint]
if: always()
# This is the summary, we just avoid to rename it so that branch protection rules still match
name: eslint
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.lint.result != 'success' }}; then exit 1; fi
@@ -1,38 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint info.xml
on: pull_request
permissions:
contents: read
concurrency:
group: lint-info-xml-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
xml-linters:
runs-on: ubuntu-latest-low
name: info.xml lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Download schema
run: wget https://raw.githubusercontent.com/nextcloud/appstore/master/nextcloudappstore/api/v1/release/info.xsd
- name: Lint info.xml
uses: ChristophWurst/xmllint-action@36f2a302f84f8c83fceea0b9c59e1eb4a616d3c1 # v1.2
with:
xml-file: ./appinfo/info.xml
xml-schema-file: ./info.xsd
-52
View File
@@ -1,52 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint php-cs
on: pull_request
permissions:
contents: read
concurrency:
group: lint-php-cs-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
name: php-cs
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get php version
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- 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
composer i
- name: Lint
run: composer run cs:check || ( echo 'Please run `composer run cs:fix` to format your code' && exit 1 )
-75
View File
@@ -1,75 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint php
on: pull_request
permissions:
contents: read
concurrency:
group: lint-php-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
matrix:
runs-on: ubuntu-latest-low
outputs:
php-versions: ${{ steps.versions.outputs.php-versions }}
steps:
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.0.0
php-lint:
runs-on: ubuntu-latest
needs: matrix
strategy:
matrix:
php-versions: ${{fromJson(needs.matrix.outputs.php-versions)}}
name: php-lint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up php ${{ matrix.php-versions }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.php-versions }}
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: Lint
run: composer run lint
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: php-lint
if: always()
name: php-lint-summary
steps:
- name: Summary status
run: if ${{ needs.php-lint.result != 'success' && needs.php-lint.result != 'skipped' }}; then exit 1; fi
@@ -1,53 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Lint stylelint
on: pull_request
permissions:
contents: read
concurrency:
group: lint-stylelint-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
name: stylelint
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
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
run: npm ci
- name: Lint
run: npm run stylelint
-107
View File
@@ -1,107 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2021-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Node
on: pull_request
permissions:
contents: read
concurrency:
group: node-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
changes:
runs-on: ubuntu-latest-low
permissions:
contents: read
pull-requests: read
outputs:
src: ${{ steps.changes.outputs.src}}
steps:
- uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
continue-on-error: true
with:
filters: |
src:
- '.github/workflows/**'
- 'src/**'
- 'appinfo/info.xml'
- 'package.json'
- 'package-lock.json'
- 'tsconfig.json'
- '**.js'
- '**.ts'
- '**.vue'
build:
runs-on: ubuntu-latest
needs: changes
if: needs.changes.outputs.src != 'false'
name: NPM build
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
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, see the section \"Show changes on failure\" for details' && exit 1)"
- name: Show changes on failure
if: failure()
run: |
git status
git --no-pager diff
exit 1 # make it red to grab attention
summary:
permissions:
contents: none
runs-on: ubuntu-latest-low
needs: [changes, build]
if: always()
# This is the summary, we just avoid to rename it so that branch protection rules still match
name: node
steps:
- name: Summary status
run: if ${{ needs.changes.outputs.src != 'false' && needs.build.result != 'success' }}; then exit 1; fi
@@ -1,81 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Npm audit fix and compile
on:
workflow_dispatch:
schedule:
# At 2:30 on Sundays
- cron: '30 2 * * 0'
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branches: ['main', 'master', 'stable31', 'stable30']
name: npm-audit-fix-${{ matrix.branches }}
steps:
- name: Checkout
id: checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
ref: ${{ matrix.branches }}
continue-on-error: true
- name: Read package.json node and npm engines version
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: versions
with:
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: Fix npm audit
id: npm-audit
uses: nextcloud-libraries/npm-audit-action@1b1728b2b4a7a78d69de65608efcf4db0e3e42d0 # v0.2.0
- name: Run npm ci and npm run build
if: steps.checkout.outcome == 'success'
env:
CYPRESS_INSTALL_BINARY: 0
run: |
npm ci
npm run build --if-present
- name: Create Pull Request
if: steps.checkout.outcome == 'success'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'fix(deps): Fix npm audit'
committer: GitHub <noreply@github.com>
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
signoff: true
branch: automated/noid/${{ matrix.branches }}-fix-npm-audit
title: '[${{ matrix.branches }}] Fix npm audit'
body: ${{ steps.npm-audit.outputs.markdown }}
labels: |
dependencies
3. to review
-96
View File
@@ -1,96 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-FileCopyrightText: 2024 Arthur Schiwon <blizzz@arthur-schiwon.de>
# SPDX-License-Identifier: MIT
name: OpenAPI
on: pull_request
permissions:
contents: read
concurrency:
group: openapi-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
openapi:
runs-on: ubuntu-latest
if: ${{ github.repository_owner != 'nextcloud-gmbh' }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get php version
id: php_versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Set up php
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ steps.php_versions.outputs.php-available }}
extensions: xml
coverage: none
ini-file: development
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check Typescript OpenApi types
id: check_typescript_openapi
uses: andstor/file-existence-action@076e0072799f4942c8bc574a82233e1e4d13e9d6 # v3.0.0
with:
files: "src/types/openapi/openapi*.ts"
- name: Read package.json node and npm engines version
if: steps.check_typescript_openapi.outputs.files_exists == 'true'
uses: skjnldsv/read-package-engines-version-actions@06d6baf7d8f41934ab630e97d9e6c0bc9c9ac5e4 # v3
id: node_versions
# Continue if no package.json
continue-on-error: true
with:
fallbackNode: '^20'
fallbackNpm: '^10'
- name: Set up node ${{ steps.node_versions.outputs.nodeVersion }}
if: ${{ steps.node_versions.outputs.nodeVersion }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ steps.node_versions.outputs.nodeVersion }}
- name: Set up npm ${{ steps.node_versions.outputs.npmVersion }}
if: ${{ steps.node_versions.outputs.nodeVersion }}
run: npm i -g 'npm@${{ steps.node_versions.outputs.npmVersion }}'
- name: Install dependencies
if: ${{ steps.node_versions.outputs.nodeVersion }}
env:
CYPRESS_INSTALL_BINARY: 0
PUPPETEER_SKIP_DOWNLOAD: true
run: |
npm ci
- name: Set up dependencies
run: composer i
- name: Regenerate OpenAPI
run: composer run openapi
- name: Check openapi*.json and typescript changes
run: |
bash -c "[[ ! \"`git status --porcelain `\" ]] || (echo 'Please run \"composer run openapi\" and commit the openapi*.json files and (if applicable) src/types/openapi/openapi*.ts, see the section \"Show changes on failure\" for details' && exit 1)"
- name: Show changes on failure
if: failure()
run: |
git status
git --no-pager diff
exit 1 # make it red to grab attention
@@ -1,87 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Static analysis
on: pull_request
concurrency:
group: psalm-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
permissions:
contents: read
jobs:
matrix:
runs-on: ubuntu-latest-low
outputs:
ocp-matrix: ${{ steps.versions.outputs.ocp-matrix }}
steps:
- name: Checkout app
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Get version matrix
id: versions
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.3.1
- name: Check enforcement of minimum PHP version ${{ steps.versions.outputs.php-min }} in psalm.xml
run: grep 'phpVersion="${{ steps.versions.outputs.php-min }}' psalm.xml
static-analysis:
runs-on: ubuntu-latest
needs: matrix
strategy:
# do not stop on another job's failure
fail-fast: false
matrix: ${{ fromJson(needs.matrix.outputs.ocp-matrix) }}
name: static-psalm-analysis ${{ matrix.ocp-version }}
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- name: Set up php${{ matrix.php-min }}
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: ${{ matrix.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
# Temporary workaround for missing pcntl_* in PHP 8.3
ini-values: disable_functions=
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: |
composer remove nextcloud/ocp --dev
composer i
- name: Install dependencies # zizmor: ignore[template-injection]
run: composer require --dev 'nextcloud/ocp:${{ matrix.ocp-version }}' --ignore-platform-reqs --with-dependencies
- name: Run coding standards check
run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github
summary:
runs-on: ubuntu-latest-low
needs: static-analysis
if: always()
name: static-psalm-analysis-summary
steps:
- name: Summary status
run: if ${{ needs.static-analysis.result != 'success' }}; then exit 1; fi
@@ -1,58 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2023-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Auto approve nextcloud/ocp
on:
pull_request_target: # zizmor: ignore[dangerous-triggers]
branches:
- main
- master
- stable*
permissions:
contents: read
concurrency:
group: update-nextcloud-ocp-approve-merge-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
auto-approve-merge:
if: github.actor == 'nextcloud-command'
runs-on: ubuntu-latest-low
permissions:
# for hmarr/auto-approve-action to approve PRs
pull-requests: write
# for alexwilson/enable-github-automerge-action to approve PRs
contents: write
steps:
- name: Disabled on forks
if: ${{ github.event.pull_request.head.repo.full_name != github.repository }}
run: |
echo 'Can not approve PRs from forks'
exit 1
- uses: mdecoleman/pr-branch-name@55795d86b4566d300d237883103f052125cc7508 # v3.0.0
id: branchname
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# GitHub actions bot approve
- uses: hmarr/auto-approve-action@b40d6c9ed2fa10c9a2749eca7eb004418a705501 # v2
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# Enable GitHub auto merge
- name: Auto merge
uses: alexwilson/enable-github-automerge-action@56e3117d1ae1540309dc8f7a9f2825bc3c5f06ff # v2.0.0
if: startsWith(steps.branchname.outputs.branch, 'automated/noid/') && endsWith(steps.branchname.outputs.branch, 'update-nextcloud-ocp')
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -1,101 +0,0 @@
# This workflow is provided via the organization template repository
#
# https://github.com/nextcloud/.github
# https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization
#
# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors
# SPDX-License-Identifier: MIT
name: Update nextcloud/ocp
on:
workflow_dispatch:
schedule:
- cron: '5 2 * * 0'
permissions:
contents: read
jobs:
update-nextcloud-ocp:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
branches: ['master']
target: ['stable30']
name: update-nextcloud-ocp-${{ matrix.branches }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
ref: ${{ matrix.branches }}
submodules: true
- name: Set up php8.2
uses: shivammathur/setup-php@cf4cade2721270509d5b1c766ab3549210a39a2a # v2.33.0
with:
php-version: 8.2
# https://docs.nextcloud.com/server/stable/admin_manual/installation/source_installation.html#prerequisites-for-manual-installation
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
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Read codeowners
id: codeowners
run: |
grep '/appinfo/info.xml' .github/CODEOWNERS | cut -f 2- -d ' ' | xargs | awk '{ print "codeowners="$0 }' >> $GITHUB_OUTPUT
continue-on-error: true
- name: Composer install
run: composer install
- name: Composer update nextcloud/ocp
id: update_branch
run: composer require --dev nextcloud/ocp:dev-${{ matrix.target }}
- name: Raise on issue on failure
uses: dacbd/create-issue-action@cdb57ab6ff8862aa09fee2be6ba77a59581921c2 # v2.0.0
if: ${{ failure() && steps.update_branch.conclusion == 'failure' }}
with:
token: ${{ secrets.GITHUB_TOKEN }}
title: 'Failed to update nextcloud/ocp package'
body: 'Please check the output of the GitHub action and manually resolve the issues<br>${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}<br>${{ steps.codeowners.outputs.codeowners }}'
- name: Reset checkout 3rdparty
run: |
git clean -f 3rdparty
git checkout 3rdparty
continue-on-error: true
- name: Reset checkout vendor
run: |
git clean -f vendor
git checkout vendor
continue-on-error: true
- name: Reset checkout vendor-bin
run: |
git clean -f vendor-bin
git checkout vendor-bin
continue-on-error: true
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.COMMAND_BOT_PAT }}
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
committer: GitHub <noreply@github.com>
author: nextcloud-command <nextcloud-command@users.noreply.github.com>
signoff: true
branch: 'automated/noid/${{ matrix.branches }}-update-nextcloud-ocp'
title: '[${{ matrix.branches }}] Update nextcloud/ocp dependency'
body: |
Auto-generated update of [nextcloud/ocp](https://github.com/nextcloud-deps/ocp/) dependency
labels: |
dependencies
3. to review
-14
View File
@@ -1,14 +0,0 @@
/.idea/
/*.iml
/vendor/
/vendor-bin/*/vendor/
/.php-cs-fixer.cache
/tests/.phpunit.cache
dist/
build/
node_modules/
js/
css/
-1
View File
@@ -1 +0,0 @@
20
-19
View File
@@ -1,19 +0,0 @@
<?php
declare(strict_types=1);
require_once './vendor-bin/cs-fixer/vendor/autoload.php';
use Nextcloud\CodingStandard\Config;
$config = new Config();
$config
->getFinder()
->notPath('build')
->notPath('l10n')
->notPath('node_modules')
->notPath('src')
->notPath('vendor')
->in(__DIR__);
return $config;
-417
View File
@@ -1,417 +0,0 @@
# Changelog - Astrolabe
All notable changes to the Astrolabe Nextcloud app will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Added
- Initial alpha release
- Semantic search across Notes, Files, Calendar, Deck, and Contacts
- Integration with Nextcloud Unified Search
- Personal settings UI for MCP server configuration
- Admin settings for global MCP server URL
- OAuth PKCE authentication flow
- Vector visualization of semantic relationships
- Hybrid search combining semantic and keyword matching
- Background content indexing
- Support for Nextcloud 30-32
### Notes
- This is an alpha release intended for early adopters and testing
- Requires external MCP server deployment
- See documentation for setup: https://github.com/cbcoutinho/nextcloud-mcp-server
## astrolabe-v0.4.4 (2025-12-20)
### Fix
- **astrolabe**: screenshots in info.xml
## astrolabe-v0.4.3 (2025-12-19)
### Fix
- **astrolabe**: screenshots in info.xml
## astrolabe-v0.4.2 (2025-12-19)
### Fix
- **astrolabe**: Update screenshots
- **ci**: skip existing Helm chart releases to prevent duplicate release errors
## astrolabe-v0.4.1 (2025-12-19)
## astrolabe-v0.4.0 (2025-12-19)
### Feat
- **ci**: add --increment flag to bump scripts for manual version control
## astrolabe-v0.3.2 (2025-12-19)
### Fix
- **astrolabe**: add contents:write permission to appstore workflow
## astrolabe-v0.3.1 (2025-12-19)
### Fix
- **astrolabe**: update commitizen pattern to properly update info.xml version
## astrolabe-v0.3.0 (2025-12-19)
### Fix
- **astrolabe**: prevent workflow failure when only helm/astrolabe commits exist
- **astrolabe**: info.xml
## astrolabe-v0.2.1 (2025-12-19)
### BREAKING CHANGE
- MCP server now bumps for ANY conventional commit except
those explicitly scoped to helm or astrolabe.
### Fix
- **ci**: push all tags explicitly in bump workflow
- **ci**: make MCP server default bump target for all non-scoped commits
- **ci**: restrict docker build to MCP server tags only
- **ci**: correct appstore-push-action version to v1.0.4
## astrolabe-v0.2.0 (2025-12-19)
### BREAKING CHANGE
- Search algorithms now require Qdrant to be populated.
Vector sync must be enabled and documents indexed for search to work.
- All OAuth deployments must be reconfigured to specify
resource URIs (NEXTCLOUD_MCP_SERVER_URL and NEXTCLOUD_RESOURCE_URI) and
choose between multi-audience or token exchange mode.
- FASTMCP_-prefixed env vars have been replaced by CLI
arguments. Refer to the README for updated usage.
### Feat
- **ci**: implement monorepo-aware version bumping workflow
- **astrolabe**: add Nextcloud App Store deployment automation
- configure commitizen monorepo with independent versioning
- add Alembic database migration system
- make chunk modal title clickable link to documents
- add native Plotly hover styling for clickable points
- add click interactivity to Plotly 3D scatter chart
- improve chunk viewer with fixed navigation and markdown rendering
- **astrolabe**: enable multi-select for document types and refactor PDF viewer
- **auth**: implement refresh token rotation for Nextcloud OIDC
- **astrolabe**: enhance unified search and add webhook management
- **astrolabe**: add webhook management UI to admin settings
- **astrolabe**: add OAuth token refresh and webhook presets
- **search**: add file_path metadata and chunk offsets to search results
- **astrolabe**: use proper icons and thumbnails in unified search
- **astrolabe**: add admin search settings and enhanced UI
- **astrolabe**: add unified search provider with clickable file links
- **astrolabe**: add 3D PCA visualization for semantic search
- **astrolabe**: add Nextcloud PHP app for MCP server management
- **vector-sync**: enable background sync in OAuth mode
- **vector**: add Deck card vector search with visualization support
- **vector-viz**: add news_item support for links and chunk expansion
- add MCP tool annotations for enhanced UX
- **news**: add Nextcloud News app integration
- Add tag management methods to WebDAV client
- Add OpenAI provider support for embeddings and generation
- Add Smithery CLI deployment support
- Implement ADR-016 Smithery stateless deployment mode
- Add context expansion to semantic search with chunk overlap removal
- Use Ollama native batch API in embed_batch()
- Implement Qdrant placeholder state management
- Switch files to use numeric IDs with file_path resolution
- Implement per-chunk vector visualization with context expansion
- Improve vector visualization with static assets and fixes
- Redesign UI to match Nextcloud ecosystem aesthetic
- Replace custom document chunker with LangChain MarkdownTextSplitter
- **viz**: Add dual-score display and improve UI controls
- add configurable fusion algorithms for BM25 hybrid search
- add chunk position tracking to vector indexing and search
- add vector viz template and chunk context endpoint
- add unified provider architecture with Amazon Bedrock support
- add concurrent uploads and --force flag to upload command
- implement RAG evaluation framework with CLI tooling
- Add OpenTelemetry tracing to @instrument_tool decorator
- Implement BM25 hybrid search with native Qdrant RRF fusion
- Normalize hybrid search RRF scores to 0-1 range
- Enhance vector visualization UI and parallelize search verification
- Add Vector Viz tab to app home page
- Add vector visualization pane with multi-select document types
- Implement custom PCA to remove sklearn dependency
- Add multi-document Protocol with cross-app search support
- Update nc_semantic_search tool with algorithm selection
- Implement unified search algorithm module
- Enable SSE transport for mcp service and update test fixtures
- Complete Phase 5 - Instrument all 93 MCP tools
- Add instrumentation decorator and apply to notes tools (Phase 5)
- Add OAuth token and database metrics (Phases 3-4)
- Add metrics instrumentation for queue, health, and database operations
- Add Grafana dashboard and vector sync metric instrumentation
- **ollama**: Pull model on startup if not available in ollama
- add dynamic vector sync status updates with htmx polling
- add webhook management UI and BeforeNodeDeletedEvent support
- validate Nextcloud webhook schemas and document findings
- skip tracing for health and metrics endpoints
- **helm**: Add document chunking configuration
- **vector**: Add configurable chunk size and overlap for document embedding
- **vector**: Support multiple embedding models with auto-generated collection names
- **helm**: Add observability support with ServiceMonitor and Grafana dashboard
- **observability**: Add comprehensive monitoring with Prometheus and OpenTelemetry
- **helm**: add Qdrant local mode support with three deployment options [skip ci]
- add Qdrant local mode support with in-memory and persistent storage
- implement ADR-009 - refactor semantic search to use generic semantic:read scope
- implement MCP sampling for semantic search RAG (ADR-008)
- add optional vector database and semantic search to helm chart
- add vector sync processing status to /user/page endpoint
- implement semantic search tool and fix vector sync issues (ADR-007 Phase 3)
- implement vector sync scanner and processor (ADR-007 Phase 2)
- add real elicitation integration test with python-sdk MCP client
- unify session architecture and enhance login status visibility
- Implement ADR-005 unified token verifier to eliminate token passthrough vulnerability
- add scope protection to OAuth provisioning tools
- enable authorization services for token exchange in Keycloak
- implement scope-based audience mapping and RFC 9728 support
- integrate token exchange into MCP server application
- implement RFC 8693 Standard Token Exchange for Keycloak
- Add userinfo route/page
- add browser-based user info page with separate OAuth flow
- Implement ADR-004 Progressive Consent foundation (partial)
- Complete ADR-004 Progressive Consent OAuth flows implementation
- Implement ADR-004 Progressive Consent foundation components
- Implement ADR-004 Hybrid Flow with comprehensive integration tests
- Auto-configure impersonation role in Keycloak realm import
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
- Add Keycloak external IdP integration with custom scopes
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
- Add Keycloak OAuth provider support with refresh token storage
- **server**: Add /live & /health endpoints
- Initialize helm chart
- Add text processing background worker for telling client about progress
- **auth**: Add support for client registration deletion
- Split read/write scopes into app:read/write scopes
- Enable token introspection for opaque tokens
- **server**: Add support for custom OIDC scopes and permissions via JWTs
- Initialize JWT-scoped tools
- **caldav**: Add support for tasks
- **webdav**: Add search and list favorite response tools
- **cookbook**: Add full Cookbook app support with 13 tools and 2 resources
- Add Groups API client
- add sharing API client and server tools
- **server**: Experimental support for OAuth2/OIDC authentication
- **users**: Initialize user API client
- **server**: Add support for `streamable-http` transport type
- Add WebDAV resource copy functionality
- Add WebDAV resource move/rename functionality
- **deck**: Add support for stack, cards, labels
- **deck**: Initialize Deck app client/server
- **cli**: Replace `mcp run` with click CLI and runtime options
- **client**: Preserve fields when modifying contacts/calendar resources
- **server**: Add structured output to all tool/resource output
- **contacts**: Initialize Contacts App
- **calendar**: add comprehensive Calendar app support via CalDAV protocol
- Update webdav client create_directory method to handle recursive directories
- **webdav**: add complete file system support
- Add TablesClient and associated tools
- Switch to using async client
- **notes**: Add append to note functionality
### Fix
- **ci**: improve versioning and error handling
- **ci**: address critical workflow and validation issues
- **astrolabe**: address code review feedback
- **security**: address critical security issues from PR #401 code review
- **oauth**: enable PKCE for all clients and add token_broker to oauth_context
- **astrolabe**: revert invalid files_pdfviewer URL for file links
- resolve type checking warnings for CI
- move Alembic to package submodule for Docker compatibility
- update unified search results to match chunk viz display
- **astrolabe**: handle OAuth refresh token rotation
- address critical code review issues (4 fixes)
- resolve CI linting issues for Astroglobe
- **news**: revert get_item() to use get_items() + filter
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
- address PR review feedback
- Update lockfile
- Revert mcp version <1.23
- resolve all type checking errors (8 errors fixed)
- **deps**: update dependency mcp to >=1.23,<1.24
- **deps**: update dependency pillow to v12
- Add rate limit retry logic to OpenAI provider
- Increase MCP sampling timeout to 5 minutes for slower LLMs
- Share vector sync state with FastMCP session lifespan via module singleton
- Share vector sync state with FastMCP session lifespan via module singleton
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
- **smithery**: Enable JSON response format for scanner compatibility
- **smithery**: Add JSON Schema metadata to mcp-config endpoint
- **smithery**: Use container runtime pattern for config discovery
- Add Smithery lifespan and auth mode detection
- Use alpha_composite for proper RGBA highlight blending
- Remove pymupdf.layout.activate() to fix page_chunks behavior
- Centralize PDF processing and generate separate images per chunk
- Set is_placeholder=False in processor to fix search filtering
- Increase placeholder staleness threshold to 5x scan interval
- Add placeholder staleness check to prevent duplicate processing
- Use empty SparseVector instead of None for placeholders
- Return empty array instead of null for query_coords when no results
- Align PDF text extraction between indexing and context expansion
- Update models and viz to use int-only doc_id
- Reconstruct full content for notes to match indexed offsets
- Add async/await, PDF metadata, and type safety fixes
- **deps**: update dependency mcp to >=1.22,<1.23
- Improve 3D plot rendering with explicit dimensions and window resize support
- Preserve 3D plot camera and improve documentation
- Preserve 3D plot camera position and fix CSS loading
- prevent infinite loop in DocumentChunker with position tracking
- Relax SearchResult validation to support DBSF fusion scores > 1.0
- suppress Starlette middleware type warnings in ty checker
- download qrels from BEIR ZIP instead of HuggingFace
- Handle named vectors in visualization and semantic search
- Update vizApp to use bm25_hybrid algorithm and remove deprecated weights
- Update viz routes to use BM25 hybrid search after refactor
- Reorder tabs and fix viz pane session access
- Use NEXTCLOUD_OIDC_CLIENT_ID/SECRET env vars consistently
- return all notes when search query is empty
- Move grafana_folder from labels to annotations
- add dynamic dimension detection for Ollama embedding models
- improve webapp tab UI with CSS Grid and viewport-filling container
- add retry logic for ETag conflicts in category change test
- optimize Notes API pagination with pruneBefore parameter
- Support in-memory Qdrant for CI testing
- **helm**: Set default strategy to Recreate
- **observability**: isolate metrics endpoint to dedicated port
- **readiness**: Only check external Qdrant in network mode
- **vector**: Handle missing 'modified' field in notes gracefully
- **ci**: Use helm dependency build instead of update to use Chart.lock
- **helm**: update Qdrant dependency condition to match new mode structure
- **ci**: add Helm repository setup to chart release workflow
- implement deletion grace period and vector sync status tool
- remove unnecessary urllib3<2.0 constraint
- integrate vector sync tasks with Starlette lifespan for streamable-http
- **deps**: update dependency mcp to >=1.21,<1.22
- Consolidate OAuth callbacks and implement PKCE for all flows
- Implement proper OAuth resource parameters and PRM-based discovery
- Simplify token verifier to be RFC 7519 compliant
- Use Keycloak client ID for NEXTCLOUD_RESOURCE_URI in token exchange
- Correct OAuth token audience validation for multi-audience mode
- **deps**: update dependency mcp to >=1.20,<1.21
- add missing await for get_nextcloud_client in capabilities resource
- use valid Fernet encryption keys in token exchange tests
- accept resource URL in token audience for Nextcloud JWT tokens
- remove token-exchange-nextcloud scope and accept tokens without audience
- move audience mapper from scope to nextcloud-mcp-server client
- move token-exchange-nextcloud from default to optional scopes
- restructure routes to prevent SessionAuthBackend from interfering with FastMCP OAuth
- allow OAuth Bearer tokens on /mcp endpoint by excluding from session auth
- correct OAuth token audience validation using RFC 8707 resource parameter
- remove remaining references to deleted oauth_callback and oauth_token
- remove Hybrid Flow, make Progressive Consent default (ADR-004)
- browser OAuth userinfo endpoint and refresh token rotation
- make ENABLE_PROGRESSIVE_CONSENT consistently opt-in (default false)
- make provisioning checks opt-in (default false)
- Disable Progressive Consent for mcp-oauth to enable Hybrid Flow tests
- Complete Keycloak external IdP integration with all tests passing
- Complete Keycloak external IdP integration with all tests passing
- Update DCR token_type tests for OIDC app changes
- **helm**: Remove image tag overide
- **helm**: Update helm chart with extraArgs
- Update helm chart variables
- **helm**: Update helm version with release
- **helm**: Update helm version with release
- **helm**: Update helm version with release
- **helm**: Update helm version with release
- Trigger release
- Add support for RFC 7592 client registration and deletion
- Update webdav models for proper serialization
- **deps**: update dependency mcp to >=1.19,<1.20
- Add CORS middleware to allow browser-based clients like MCP Inspector
- Use occ-created OAuth clients with allowed_scopes for all tests
- Separate OAuth fixtures for opaque vs JWT tokens
- **caldav**: Fix caldav search() due to missing todos
- **caldav**: Check that calendar exists after creation to avoid race condition
- **caldav**: Properly parse datetimes as vDDDTypes
- Increase HTTP client timeout to 30s
- Handle RequestError in mcp tools
- **deps**: update dependency mcp to >=1.18,<1.19
- **deps**: update dependency pillow to v12
- **oauth**: Remove the option to force_register new clients
- Update user/groups API to OCS v2
- **deps**: update dependency mcp to >=1.17,<1.18
- **deps**: update dependency mcp to >=1.16,<1.17
- **deps**: update dependency mcp to >=1.15,<1.16
- **docker**: Provide --host 0.0.0.0 in default docker image
- **deps**: update dependency mcp to >=1.13,<1.14
- **server**: Replace ErrorResponses with standard McpErrors
- **notes**: Include ETags in responses to avoid accidently updates
- **notes**: Remove note contents from responses to reduce token usage
- **model**: Serialize timestamps in RFC3339 format
- **client**: Use paging to fetch all notes
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
- **calendar**: Fix iCalendar date vs datetime format
- **calendar**: Remove try/except in calendar API
- apply ruff formatting to pass CI checks
- **calendar**: address PR feedback from maintainer
- apply ruff formatting to test_webdav_operations.py
- **deps**: update dependency mcp to >=1.10,<1.11
- update tests
- Commitizen release process
- Do not update dependencies when running in Dockerfile
- Configure logging
- Limit search results to notes with score > 0.5
- Install deps before checking service
- **deps**: update dependency mcp to >=1.9,<1.10
### Refactor
- **astrolabe**: extract PDF viewer to dedicated component
- **astrolabe**: reframe UI as semantic search service
- **news**: simplify vector sync to fetch all items
- Move background tasks to server lifespan and deprecate SSE transport
- Simplify PDF text extraction with single to_markdown call
- migrate asyncio to anyio for consistent structured concurrency
- replace httpx client with NextcloudClient in upload command
- Optimize Nextcloud access verification with centralized filtering
- Make all search algorithms query Qdrant payload, not Nextcloud
- move webapp from /user/page to /app
- consolidate database storage for webhooks and OAuth tokens
- simplify OpenTelemetry tracing configuration
- migrate vector sync from asyncio.Queue to anyio memory object streams
- update to Qdrant query_points API and fix Playwright Keycloak login
- Eliminate duplicate validation logic in UnifiedTokenVerifier
- integrate token exchange into unified get_client() pattern
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
- Unify OAuth configuration to be provider-agnostic
- Transform document parsing into pluggable processor architecture
- Update JWT client to use DCR, re-enable tool filtering
- Migrate from internal CalendarClient to caldav library
- Unify logging & remove factory deployment
- Add tools for all resources to enable tool-only workflows
- Add `http` to --transport option
- Use _make_request where available
- **calendar**: optimize logging for production readiness
- Modularize NC and Notes app client
### Perf
- **deck**: optimize card lookup by storing board_id/stack_id in metadata
- **news**: use direct API endpoint for get_item()
- Optimize vector viz search performance
- Optimize PDF processing with parallel extraction and single-render highlights
- Eliminate double-fetching in semantic search sampling
- fix vector viz search performance and visual encoding
- make note deletion concurrent in upload --force
- Exclude vector-sync status polling from distributed tracing
- **notes**: Improve notes search performance using async iterators
-9
View File
@@ -1,9 +0,0 @@
In the Nextcloud community, participants from all over the world come together to create Free Software for a free internet. This is made possible by the support, hard work and enthusiasm of thousands of people, including those who create and use Nextcloud software.
Our code of conduct offers some guidance to ensure Nextcloud participants can cooperate effectively in a positive and inspiring atmosphere, and to explain how together we can strengthen and support each other.
The Code of Conduct is shared by all contributors and users who engage with the Nextcloud team and its community services. It presents a summary of the shared values and “common sense” thinking in our community.
You can find our full code of conduct on our website: https://nextcloud.com/code-of-conduct/
Please, keep our CoC in mind when you contribute! That way, everyone can be a part of our community in a productive, positive, creative and fun way.
-661
View File
@@ -1,661 +0,0 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
-101
View File
@@ -1,101 +0,0 @@
# Nextcloud App Store Release Makefile for Astrolabe
#
# Based on: https://nextcloudappstore.readthedocs.io/en/latest/developer.html
app_name=astrolabe
project_dir=$(CURDIR)
build_dir=$(project_dir)/build
appstore_dir=$(build_dir)/artifacts
package_name=$(appstore_dir)/$(app_name)
cert_dir=$(HOME)/.nextcloud/certificates
# Nextcloud server path (configurable via environment variable)
server_dir?=../../server
occ=$(server_dir)/occ
# Signing
private_key=$(cert_dir)/$(app_name).key
certificate=$(cert_dir)/$(app_name).crt
sign_cmd=php $(occ) integrity:sign-app --privateKey=$(private_key) --certificate=$(certificate)
# Clean build artifacts
.PHONY: clean
clean:
rm -rf $(build_dir)
# Validate required dependencies
.PHONY: validate-deps
validate-deps:
@command -v composer >/dev/null 2>&1 || { echo "Error: composer not found. Install from https://getcomposer.org/"; exit 1; }
@command -v npm >/dev/null 2>&1 || { echo "Error: npm not found. Install Node.js from https://nodejs.org/"; exit 1; }
@command -v php >/dev/null 2>&1 || { echo "Error: php not found. Install PHP 8.1 or higher."; exit 1; }
@echo "✓ All dependencies found"
# Install PHP and Node dependencies
.PHONY: install-deps
install-deps: validate-deps
composer install --no-dev --optimize-autoloader
npm ci
# Build production frontend assets
.PHONY: build-frontend
build-frontend:
npm run build
# Run all linters
.PHONY: lint
lint:
composer lint
composer cs:check
npm run lint
npm run stylelint
# Assemble app files into build directory (exclude dev files)
.PHONY: assemble
assemble: clean install-deps build-frontend
mkdir -p $(package_name)
# Copy app files
rsync -av \
--exclude='.git*' \
--exclude='build/' \
--exclude='tests/' \
--exclude='node_modules/' \
--exclude='*.log' \
--exclude='.github/' \
--exclude='composer.json' \
--exclude='composer.lock' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='vite.config.js' \
--exclude='.eslintrc.js' \
--exclude='.php-cs-fixer.*' \
--exclude='psalm.xml' \
--exclude='*.iml' \
--exclude='.idea' \
--exclude='src/' \
./ $(package_name)/
# Validate signing prerequisites
.PHONY: validate-signing
validate-signing:
@test -f $(occ) || { echo "Error: Nextcloud server not found at $(server_dir)"; echo "Set server_dir variable: make appstore server_dir=/path/to/server"; exit 1; }
@test -f $(private_key) || { echo "Error: Private key not found at $(private_key)"; exit 1; }
@test -f $(certificate) || { echo "Error: Certificate not found at $(certificate)"; exit 1; }
@echo "✓ Signing prerequisites validated"
# Create signed release tarball for App Store
.PHONY: appstore
appstore: assemble validate-signing
# Sign the app
$(sign_cmd) --path=$(package_name)
# Create tarball
cd $(appstore_dir) && \
tar -czf $(app_name).tar.gz $(app_name)
# Show package info
@echo "========================================="
@echo "App package created:"
@echo " $(appstore_dir)/$(app_name).tar.gz"
@echo ""
@echo "Signature:"
@cat $(package_name)/appinfo/signature.json | head -n 5
@echo "========================================="
-223
View File
@@ -1,223 +0,0 @@
# Astrolabe: The Intelligence Layer for Nextcloud
Your Nextcloud instance is more than just a bucket for files—it is a galaxy of ideas, projects, and knowledge. But until now, you've been navigating it in the dark, relying on exact filenames and rigid keywords.
**It's time to turn the lights on.**
Astrolabe is a fully integrated Nextcloud application that transforms your server into a semantic intelligence engine. It doesn't just store your data; it **maps it, understands it, and connects it** to the AI future.
---
## What You Can Do
### 🔍 Search That Actually Understands
Forget clunky external tools. Astrolabe registers as a **native Nextcloud Search Provider**.
- **Seamless**: Lives right in the standard Nextcloud search bar you already use
- **Semantic**: Type "marketing strategy for the winter launch" and Astrolabe finds the relevant PDFs, chat logs, and text files—even if those exact words never appear in the document
- **Intelligent**: It finds the **concept**, not just the string
### 🌌 Visualize Your Data Universe
Data shouldn't just be a list; it should be a landscape. Astrolabe includes a dedicated dashboard that visualizes your document chunks as a **3D PCA Vector Plot**.
- **See the Connections**: View your data as a constellation of points in 3D space
- **Explore Clusters**: Visually identify how your documents relate to one another
- **True "Astroglobe" Experience**: Rotate, zoom, and fly through your semantic universe just like navigators once studied the stars
### 🤖 Power Your AI Agents
Astrolabe isn't just for humans; it's for your AI agents, too. It acts as a bridge, running a **Model Context Protocol (MCP) Server** directly from your Nextcloud.
- **Bring Your Own Brain**: Connect external AI clients (like Claude Desktop or Cursor) to your private data
- **Agentic Workflows**: Enable LLMs to "sample" your files, read content, and perform complex reasoning tasks using your Nextcloud data as the source of truth
- **Private & Secure**: Your data never leaves your infrastructure
---
## Installation
### From App Store (Recommended)
1. Open **Apps** in your Nextcloud
2. Search for **"Astrolabe"**
3. Click **"Download and enable"**
### Manual Installation
```bash
# Clone into your Nextcloud apps directory
cd /path/to/nextcloud/apps
git clone https://github.com/cbcoutinho/nextcloud-mcp-server.git
cd nextcloud-mcp-server/third_party/astrolabe
# Install dependencies
composer install
# Enable the app
php /path/to/nextcloud/occ app:enable astrolabe
```
---
## Quick Start
### 1. Configure the MCP Server URL
Add this to your Nextcloud `config/config.php`:
```php
'mcp_server_url' => 'http://localhost:8000',
```
### 2. Start the MCP Server
The MCP server handles semantic search and AI agent connections. See the [MCP Server Installation Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md) for details.
Quick start with Docker:
```bash
docker run -p 127.0.0.1:8000:8000 --env-file .env --rm \
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest --oauth
```
### 3. Authorize Access
1. Go to **Settings → Personal → Astrolabe**
2. Click **"Authorize Access"**
3. Sign in to your identity provider
4. Approve the requested permissions
That's it! You can now use semantic search and explore your data universe.
---
## Features
### Personal Settings
Located in: **Settings → Personal → Astrolabe**
- **Semantic Search Dashboard**: Interactive 3D visualization of your document chunks
- **OAuth Authorization**: Authorize Nextcloud to access the MCP server on your behalf
- **Session Information**: View connection status and authentication details
- **Connection Management**: Revoke access or disconnect when needed
### Admin Settings
Located in: **Settings → Administration → Astrolabe**
- **Server Status**: Monitor MCP server health and version
- **Vector Sync Metrics**: See how many documents are indexed, processing rates, and sync status
- **Configuration Validation**: Verify server URL and connectivity
- **Feature Availability**: Check which capabilities are enabled
### Unified Search Integration
Astrolabe integrates directly with Nextcloud's **Unified Search**:
- Available in the top search bar across all Nextcloud pages
- Returns semantic matches ranked by relevance
- Shows excerpts from matching documents
- Links directly to source files in Nextcloud
---
## Use Cases
### For Individuals
- **Research**: Find all notes related to a project, even if they use different terminology
- **Organization**: Discover forgotten documents related to your current work
- **Exploration**: Visualize how your knowledge connects and evolves over time
### For Teams
- **Knowledge Discovery**: Surface institutional knowledge that would otherwise stay buried
- **Collaboration**: Find team members working on similar problems
- **Documentation**: Locate relevant documentation without knowing exact titles
### For Developers
- **AI Integration**: Connect Claude Desktop, Cursor, or other MCP clients to Nextcloud
- **RAG Workflows**: Build retrieval-augmented generation pipelines on your private data
- **Custom Agents**: Use the MCP protocol to create specialized workflows
---
## Requirements
- **Nextcloud**: Version 30 or later
- **MCP Server**: Running instance (Docker recommended)
- **Identity Provider**: OAuth provider supporting PKCE (Nextcloud OIDC Login or Keycloak)
- **Vector Sync**: Optional but recommended for semantic search (see [configuration guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md))
---
## Documentation
### User Guides
- [MCP Server Installation](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/installation.md)
- [Configuration Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/configuration.md)
- [OAuth Setup](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/oauth-setup.md)
### Technical Details
- [ADR-018: Nextcloud PHP App Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-018-nextcloud-php-app-for-settings-ui.md)
- [OAuth PKCE Flow Details](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-004-progressive-consent.md)
- [Vector Sync Architecture](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/ADR-002-vector-sync-authentication.md)
### Troubleshooting
**Cannot connect to MCP server:**
- Verify `mcp_server_url` in `config.php`
- Check MCP server is running: `curl http://localhost:8000/health`
- Review logs: `tail -f data/nextcloud.log`
**Authorization fails:**
- Ensure MCP server is in OAuth mode
- Verify identity provider is accessible
- Check browser console for errors
**Semantic search returns no results:**
- Verify vector sync is enabled and running
- Check indexing status in Admin settings
- Allow time for initial indexing to complete
For more help, see the [Troubleshooting Guide](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/docs/troubleshooting.md).
---
## Contributing
We welcome contributions! Here's how to get started:
1. Fork the [nextcloud-mcp-server repository](https://github.com/cbcoutinho/nextcloud-mcp-server)
2. Create a feature branch: `git checkout -b feature/your-feature`
3. Make your changes in `third_party/astrolabe/`
4. Test thoroughly with a local Nextcloud instance
5. Submit a pull request
See [CONTRIBUTING.md](https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CONTRIBUTING.md) for detailed guidelines.
---
## License
AGPL-3.0
---
## About
**Astrolabe** is developed as part of the [Nextcloud MCP Server](https://github.com/cbcoutinho/nextcloud-mcp-server) project, bringing the power of semantic search and AI integration to Nextcloud.
**Author**: Chris Coutinho <chris@coutinho.io>
---
**Your Data. Mapped. Visualized. Connected.**
Install Astrolabe for Nextcloud.
-60
View File
@@ -1,60 +0,0 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>astrolabe</id>
<name>Astrolabe</name>
<summary>AI-powered semantic search across your Nextcloud</summary>
<description><![CDATA[
# Astrolabe - Semantic Search for Nextcloud
Find your content by meaning, not just keywords. Astrolabe brings AI-powered semantic search to your Nextcloud, helping you discover documents, notes, calendar events, and files through natural language.
## Features
- **Semantic Search**: Search across Notes, Files, Calendar, Deck, and more using natural language queries
- **Unified Search Integration**: Results appear in Nextcloud's global search bar alongside traditional results
- **Background Indexing**: Your content is automatically indexed for instant semantic search
- **Vector Visualization**: Explore your content in an interactive 2D visualization showing semantic relationships
- **Hybrid Search**: Combines semantic understanding with keyword matching for best results
## How It Works
Astrolabe connects to a semantic search service that understands the meaning of your content. Instead of exact keyword matches, you can search for concepts - ask "meeting notes from last week" or "recipes with chicken" and find relevant documents even if they don't contain those exact words.
## Getting Started
1. Install and enable the app
2. Grant background access to allow indexing of your content
3. Start searching with natural language in Nextcloud's search bar
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
]]></description>
<version>0.4.4</version>
<licence>agpl</licence>
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
<namespace>Astrolabe</namespace>
<category>ai</category>
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/02-semantic-search-with-plot.png?raw=1</screenshot>
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/01-unified-search-astrolabe.png?raw=1</screenshot>
<screenshot>https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/third_party/astrolabe/screenshots/03-chunk-viewer-open.png?raw=1</screenshot>
<dependencies>
<nextcloud min-version="30" max-version="32"/>
</dependencies>
<settings>
<personal>OCA\Astrolabe\Settings\Personal</personal>
<personal-section>OCA\Astrolabe\Settings\PersonalSection</personal-section>
<admin>OCA\Astrolabe\Settings\Admin</admin>
<admin-section>OCA\Astrolabe\Settings\AdminSection</admin-section>
</settings>
<navigations>
<navigation>
<id>astrolabe</id>
<name>Astrolabe</name>
<route>astrolabe.page.index</route>
<icon>app.svg</icon>
<type>link</type>
</navigation>
</navigations>
</info>

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