Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 981f102b27 | |||
| 94febf1602 | |||
| 286a3eb20f | |||
| 19b209f412 | |||
| cd7ba5685a | |||
| 4507359760 | |||
| 8682fa4f88 | |||
| 53b84200d4 | |||
| f5e5965864 | |||
| 989c3d7541 | |||
| 4bda647271 | |||
| 32f3380205 | |||
| 0d6b8a935d | |||
| eece9ebadc | |||
| c390378278 | |||
| bd424a1ab7 | |||
| f8734b3edd | |||
| 0ea7145df1 | |||
| f7a3d2d8f5 | |||
| 18298177f7 | |||
| d9fa81082a | |||
| 651b73545d | |||
| 46505210cd | |||
| abf051afdb | |||
| d4d1a332fb | |||
| a3ed321e14 | |||
| 2bb738ed3f | |||
| 10c8b62818 | |||
| 87abadbbfc | |||
| defc55a5dc | |||
| 6a68e45e7c | |||
| a2fa4b2832 | |||
| 9cfadbfc04 | |||
| 6fed78196e | |||
| db430dd2c9 | |||
| 3618aed39e | |||
| 4c083c7314 | |||
| 3202640cf7 | |||
| c9bbe71869 | |||
| 00edb273cd | |||
| 608b3282dd | |||
| 2888bd5693 | |||
| 90d95da48d | |||
| 31fb52761e | |||
| f7e651d0bc | |||
| ff41fb37fd | |||
| 776c8ad3f7 | |||
| db97bf8654 | |||
| e2e0ffce44 | |||
| 2f3a3e0be4 | |||
| c5f7221fb2 | |||
| 4a42b947bc | |||
| 46b260641f | |||
| 60d80970a4 | |||
| cb7f9cec2d | |||
| 6babbc99e7 | |||
| 1f5e9d815b | |||
| 83caa48cdb | |||
| b51019a7e8 | |||
| 72d65cd7ae | |||
| 76251e935e | |||
| 49230c3a44 | |||
| 262d2b2133 | |||
| ad2ff2ccc4 | |||
| dff7a58736 | |||
| 44c9bd645e | |||
| 4741d60e4c |
@@ -0,0 +1,89 @@
|
|||||||
|
name: Build and Publish Astrolabe App Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'astrolabe-v*'
|
||||||
|
|
||||||
|
env:
|
||||||
|
APP_NAME: astrolabe
|
||||||
|
APP_DIR: third_party/astrolabe
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
|
||||||
|
- name: Get version from tag
|
||||||
|
id: tag
|
||||||
|
run: |
|
||||||
|
echo "TAG=${GITHUB_REF#refs/tags/astrolabe-v}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Validate version in info.xml matches tag
|
||||||
|
working-directory: ${{ env.APP_DIR }}
|
||||||
|
run: |
|
||||||
|
INFO_VERSION=$(sed -n 's/.*<version>\(.*\)<\/version>.*/\1/p' appinfo/info.xml | tr -d '\t')
|
||||||
|
if [ "$INFO_VERSION" != "${{ steps.tag.outputs.TAG }}" ]; then
|
||||||
|
echo "Version mismatch: info.xml has $INFO_VERSION but tag is ${{ steps.tag.outputs.TAG }}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Version validated: $INFO_VERSION"
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup PHP
|
||||||
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||||
|
with:
|
||||||
|
php-version: 8.1
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Checkout Nextcloud server (for signing)
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
repository: nextcloud/server
|
||||||
|
ref: stable30
|
||||||
|
path: server
|
||||||
|
|
||||||
|
- name: Install dependencies and build
|
||||||
|
working-directory: ${{ env.APP_DIR }}
|
||||||
|
run: |
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
- name: Setup signing certificate
|
||||||
|
run: |
|
||||||
|
mkdir -p $HOME/.nextcloud/certificates
|
||||||
|
echo "${{ secrets.APP_PRIVATE_KEY }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.key
|
||||||
|
echo "${{ secrets.APP_PUBLIC_CRT }}" > $HOME/.nextcloud/certificates/${{ env.APP_NAME }}.crt
|
||||||
|
|
||||||
|
- name: Build app store package
|
||||||
|
working-directory: ${{ env.APP_DIR }}
|
||||||
|
run: make appstore server_dir=${{ github.workspace }}/server
|
||||||
|
|
||||||
|
- name: Create GitHub release and attach tarball
|
||||||
|
uses: svenstaro/upload-release-action@6b7fa9f267e90b50a19fef07b3596790bb941741 # v2
|
||||||
|
with:
|
||||||
|
repo_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
file: ${{ env.APP_DIR }}/build/artifacts/${{ env.APP_NAME }}.tar.gz
|
||||||
|
asset_name: ${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||||
|
tag: ${{ github.ref }}
|
||||||
|
release_name: Astrolabe ${{ steps.tag.outputs.TAG }}
|
||||||
|
prerelease: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||||
|
|
||||||
|
- name: Upload to Nextcloud App Store
|
||||||
|
uses: R0Wi/nextcloud-appstore-push-action@9244bb5445776688cfe90fa1903ea8dff95b0c28 # v1.0.4
|
||||||
|
with:
|
||||||
|
app_name: ${{ env.APP_NAME }}
|
||||||
|
appstore_token: ${{ secrets.APPSTORE_TOKEN }}
|
||||||
|
download_url: ${{ github.server_url }}/${{ github.repository }}/releases/download/${{ github.ref_name }}/${{ env.APP_NAME }}-${{ steps.tag.outputs.TAG }}.tar.gz
|
||||||
|
app_private_key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||||
|
nightly: ${{ contains(steps.tag.outputs.TAG, '-alpha') || contains(steps.tag.outputs.TAG, '-beta') || contains(steps.tag.outputs.TAG, '-rc') }}
|
||||||
@@ -7,9 +7,9 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
bump-version:
|
bump-version:
|
||||||
if: "!startsWith(github.event.head_commit.message, 'bump:')"
|
if: "!startsWith(github.event.head_commit.message, 'bump:') && !startsWith(github.event.head_commit.message, 'chore(release):')"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: "Bump version and create changelog with commitizen"
|
name: "Bump version and create changelog for monorepo components"
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
packages: write
|
||||||
@@ -19,14 +19,147 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
- name: Create bump and changelog
|
|
||||||
uses: commitizen-tools/commitizen-action@bb4f1df6601e2a1a891506581b0c53acdc88e07d # 0.26.0
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
python-version: '3.11'
|
||||||
changelog_increment_filename: body.md
|
|
||||||
- name: Release
|
- name: Install uv
|
||||||
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
run: |
|
||||||
with:
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
body_path: "body.md"
|
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
|
||||||
tag_name: v${{ env.REVISION }}
|
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
- 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
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.bump.outputs.bumped }}" == "true" ]; then
|
||||||
|
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The following components were bumped:" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
|
||||||
|
for component in ${{ steps.bump.outputs.components }}; do
|
||||||
|
case $component in
|
||||||
|
mcp)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^v[0-9]' | head -n 1)
|
||||||
|
echo "- **MCP Server**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
helm)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^nextcloud-mcp-server-' | head -n 1)
|
||||||
|
echo "- **Helm Chart**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
astrolabe)
|
||||||
|
tag=$(git tag --sort=-creatordate | grep -E '^astrolabe-v' | head -n 1)
|
||||||
|
echo "- **Astrolabe**: \`$tag\`" >> $GITHUB_STEP_SUMMARY
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "Tags have been pushed and release workflows will trigger automatically." >> $GITHUB_STEP_SUMMARY
|
||||||
|
else
|
||||||
|
echo "## Version Bump Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "✅ No version bumps required - no relevant commits found since last release." >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "The workflow completed successfully with no changes." >> $GITHUB_STEP_SUMMARY
|
||||||
|
fi
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
|
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
prompt: |
|
prompt: |
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
|
uses: anthropics/claude-code-action@d7b6d50442a89f005016e778bf825a72ef582525 # v1
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ name: Build and Publish Docker Image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ["*"]
|
tags:
|
||||||
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -33,7 +34,7 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Run chart-releaser
|
- name: Run chart-releaser
|
||||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||||
|
with:
|
||||||
|
skip_existing: true
|
||||||
env:
|
env:
|
||||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -48,6 +48,23 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
|
|
||||||
|
###### Required to build Astrolabe App ######
|
||||||
|
|
||||||
|
- name: Set up Node.js for Astrolabe
|
||||||
|
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Build Astrolabe app
|
||||||
|
run: |
|
||||||
|
cd third_party/astrolabe
|
||||||
|
composer install --no-dev --optimize-autoloader
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
###### Required to build Astrolabe App ######
|
||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
@@ -56,7 +73,7 @@ jobs:
|
|||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
uses: astral-sh/setup-uv@681c641aba71e4a1c380be3ab5e12ad51f415867 # v7.1.6
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+109
@@ -1,3 +1,112 @@
|
|||||||
|
# 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)
|
## v0.52.1 (2025-12-13)
|
||||||
|
|
||||||
### Perf
|
### Perf
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
# Contributing to Nextcloud MCP Server
|
||||||
|
|
||||||
|
## Version Management
|
||||||
|
|
||||||
|
This monorepo uses commitizen for version management with **independent versioning** for three components:
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
| Component | Scope | Bump Command | Tag Example |
|
||||||
|
|-----------|-------|--------------|-------------|
|
||||||
|
| MCP Server | `mcp` or none | `./scripts/bump-mcp.sh` | `v0.54.0` |
|
||||||
|
| Helm Chart | `helm` | `./scripts/bump-helm.sh` | `nextcloud-mcp-server-0.54.0` |
|
||||||
|
| Astrolabe App | `astrolabe` | `./scripts/bump-astrolabe.sh` | `astrolabe-v0.2.0` |
|
||||||
|
|
||||||
|
### Commit Message Format
|
||||||
|
|
||||||
|
Use conventional commits with **scopes** to target specific components:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# MCP server changes
|
||||||
|
feat(mcp): add calendar sync API
|
||||||
|
fix(mcp): resolve authentication bug
|
||||||
|
|
||||||
|
# Helm chart changes
|
||||||
|
feat(helm): add resource limits
|
||||||
|
docs(helm): update values documentation
|
||||||
|
|
||||||
|
# Astrolabe app changes
|
||||||
|
feat(astrolabe): add dark mode toggle
|
||||||
|
fix(astrolabe): resolve search UI bug
|
||||||
|
```
|
||||||
|
|
||||||
|
**Unscoped commits** default to the MCP server:
|
||||||
|
```bash
|
||||||
|
feat: add new feature # → MCP server (v0.54.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Release Workflow
|
||||||
|
|
||||||
|
#### 1. Make Changes with Scoped Commits
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git commit -m "feat(astrolabe): add dark mode toggle"
|
||||||
|
git commit -m "feat(helm): add ingress annotations"
|
||||||
|
git commit -m "feat(mcp): add calendar sync"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Bump Component Versions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bump MCP server (reads commits with scope=mcp or unscoped)
|
||||||
|
./scripts/bump-mcp.sh
|
||||||
|
# → Creates tag: v0.54.0
|
||||||
|
# → Updates: pyproject.toml, Chart.yaml:appVersion
|
||||||
|
|
||||||
|
# Bump Helm chart (reads commits with scope=helm)
|
||||||
|
./scripts/bump-helm.sh
|
||||||
|
# → Creates tag: nextcloud-mcp-server-0.54.0
|
||||||
|
# → Updates: Chart.yaml:version
|
||||||
|
|
||||||
|
# Bump Astrolabe (reads commits with scope=astrolabe)
|
||||||
|
./scripts/bump-astrolabe.sh
|
||||||
|
# → Creates tag: astrolabe-v0.2.0
|
||||||
|
# → Updates: info.xml, package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Push Tags
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push --follow-tags
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changelog Filtering
|
||||||
|
|
||||||
|
Each component maintains its own `CHANGELOG.md`:
|
||||||
|
|
||||||
|
- **MCP Server**: `CHANGELOG.md` (root) - includes `feat(mcp):` and unscoped commits
|
||||||
|
- **Helm Chart**: `charts/nextcloud-mcp-server/CHANGELOG.md` - includes `feat(helm):` only
|
||||||
|
- **Astrolabe**: `third_party/astrolabe/CHANGELOG.md` - includes `feat(astrolabe):` only
|
||||||
|
|
||||||
|
### Manual Version Bumps
|
||||||
|
|
||||||
|
For specific increments:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Patch bump (0.53.0 → 0.53.1)
|
||||||
|
uv run cz bump --increment PATCH
|
||||||
|
|
||||||
|
# Minor bump (0.53.0 → 0.54.0)
|
||||||
|
uv run cz bump --increment MINOR
|
||||||
|
|
||||||
|
# Major bump (0.53.0 → 1.0.0)
|
||||||
|
uv run cz bump --increment MAJOR
|
||||||
|
|
||||||
|
# For non-MCP components, use --config
|
||||||
|
cd charts/nextcloud-mcp-server
|
||||||
|
uv run cz --config .cz.toml bump --increment MINOR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Versioning Philosophy
|
||||||
|
|
||||||
|
- **MCP Server**: Follows PEP 440, `major_version_zero = true` (0.x.x for pre-1.0)
|
||||||
|
- **Helm Chart**: Follows PEP 440, starts at 0.53.0 (continues from current)
|
||||||
|
- **Astrolabe**: Follows PEP 440, `major_version_zero = true` (0.x.x for alpha/beta)
|
||||||
|
|
||||||
|
### Chart.yaml Version vs appVersion
|
||||||
|
|
||||||
|
The Helm chart has TWO version fields:
|
||||||
|
|
||||||
|
- **`version`**: Chart packaging version (bumped by `feat(helm):`)
|
||||||
|
- Example: `0.53.0` → `0.54.0` when adding resource limits
|
||||||
|
|
||||||
|
- **`appVersion`**: MCP server version being deployed (bumped by `feat(mcp):`)
|
||||||
|
- Example: `"0.53.0"` → `"0.54.0"` when MCP server releases
|
||||||
|
|
||||||
|
This allows the chart to evolve independently from the application.
|
||||||
+1
-1
@@ -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:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.18@sha256:5713fa8217f92b80223bc83aac7db36ec80a84437dbc0d04bbc659cae030d8c9 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
set -euox pipefail
|
set -euox pipefail
|
||||||
|
|
||||||
echo "Installing and configuring Astrolabe app for testing..."
|
echo "Installing Astrolabe app for testing..."
|
||||||
|
|
||||||
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
# Check if development astrolabe app is mounted at /opt/apps/astrolabe
|
||||||
if [ -d /opt/apps/astrolabe ]; then
|
if [ -d /opt/apps/astrolabe ]; then
|
||||||
@@ -30,55 +30,7 @@ else
|
|||||||
php /var/www/html/occ app:enable astrolabe
|
php /var/www/html/occ app:enable astrolabe
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Configure MCP server URLs in Nextcloud system config
|
echo "✓ Astrolabe app installed successfully"
|
||||||
# - mcp_server_url: Internal URL for PHP app to call MCP server APIs (Docker internal network)
|
echo ""
|
||||||
# - mcp_server_public_url: Public URL for OAuth token audience (what browsers/MCP clients see)
|
echo "Note: MCP server configuration is managed dynamically during tests"
|
||||||
php /var/www/html/occ config:system:set mcp_server_url --value='http://mcp-oauth:8001'
|
echo " to support testing multiple MCP server deployments."
|
||||||
php /var/www/html/occ config:system:set mcp_server_public_url --value='http://localhost:8001'
|
|
||||||
|
|
||||||
# Create OAuth client for Astrolabe app
|
|
||||||
# The resource_url MUST match what the MCP server expects as token audience
|
|
||||||
# This allows tokens from this client to be validated by MCP server's UnifiedTokenVerifier
|
|
||||||
MCP_CLIENT_ID="nextcloudMcpServerUIPublicClient"
|
|
||||||
MCP_RESOURCE_URL="http://localhost:8001"
|
|
||||||
MCP_REDIRECT_URI="http://localhost:8080/apps/astrolabe/oauth/callback"
|
|
||||||
|
|
||||||
echo "Configuring OAuth client for Astrolabe..."
|
|
||||||
|
|
||||||
# Check if client already exists
|
|
||||||
if php /var/www/html/occ oidc:list 2>/dev/null | grep -q "$MCP_CLIENT_ID"; then
|
|
||||||
echo "OAuth client $MCP_CLIENT_ID already exists, removing to recreate with correct settings..."
|
|
||||||
php /var/www/html/occ oidc:remove "$MCP_CLIENT_ID" || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create OAuth client with correct resource_url for MCP server audience
|
|
||||||
echo "Creating OAuth confidential client with resource_url=$MCP_RESOURCE_URL"
|
|
||||||
CLIENT_OUTPUT=$(php /var/www/html/occ oidc:create \
|
|
||||||
"Astrolabe" \
|
|
||||||
"$MCP_REDIRECT_URI" \
|
|
||||||
--client_id="$MCP_CLIENT_ID" \
|
|
||||||
--type=confidential \
|
|
||||||
--flow=code \
|
|
||||||
--token_type=jwt \
|
|
||||||
--resource_url="$MCP_RESOURCE_URL" \
|
|
||||||
--allowed_scopes="openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write")
|
|
||||||
|
|
||||||
echo "$CLIENT_OUTPUT"
|
|
||||||
|
|
||||||
# Extract client_secret from JSON output
|
|
||||||
CLIENT_SECRET=$(echo "$CLIENT_OUTPUT" | php -r 'echo json_decode(file_get_contents("php://stdin"), true)["client_secret"] ?? "";')
|
|
||||||
|
|
||||||
if [ -n "$CLIENT_SECRET" ]; then
|
|
||||||
echo "Configuring Astrolabe client secret in system config..."
|
|
||||||
php /var/www/html/occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET"
|
|
||||||
echo "✓ Client secret configured: ${CLIENT_SECRET:0:8}..."
|
|
||||||
else
|
|
||||||
echo "⚠ Warning: Could not extract client_secret from OIDC client creation"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Configure OAuth client ID in system config
|
|
||||||
echo "Configuring Astrolabe client ID in system config..."
|
|
||||||
php /var/www/html/occ config:system:set astrolabe_client_id --value="$MCP_CLIENT_ID"
|
|
||||||
echo "✓ Client ID configured: $MCP_CLIENT_ID"
|
|
||||||
|
|
||||||
echo "Astrolabe app installed and configured successfully"
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
[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}}"
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
# 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
|
||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.16.2
|
version: 1.16.2
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.35.0
|
version: 1.36.0
|
||||||
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
|
digest: sha256:fab8008217b27ff4e3e139c2f481eedaa23f9f64a3a086d0e9deea2195b69b63
|
||||||
generated: "2025-12-05T11:11:27.999374001Z"
|
generated: "2025-12-14T11:07:07.024787592Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.52.1
|
version: 0.54.0
|
||||||
appVersion: "0.52.1"
|
appVersion: "0.56.2"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -31,6 +31,6 @@ dependencies:
|
|||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.35.0"
|
version: "1.36.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+34
-2
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
|
image: docker.io/library/nextcloud:32.0.3@sha256:53231a9fb9233af2c15bfe70fc03ebe639fd53243fa42a9369884b1e0008deae
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
|
image: docker.io/library/nginx:alpine@sha256:052b75ab72f690f33debaa51c7e08d9b969a0447a133eb2b99cc905d9188cb2b
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
@@ -123,6 +123,32 @@ services:
|
|||||||
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
# - DOCUMENT_CHUNK_SIZE=512 # Words per chunk (default: 512)
|
||||||
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
# - DOCUMENT_CHUNK_OVERLAP=50 # Overlapping words (default: 50, recommended: 10-20% of chunk size)
|
||||||
|
|
||||||
|
mcp-multi-user-basic:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
command: ["--transport", "streamable-http"]
|
||||||
|
depends_on:
|
||||||
|
app:
|
||||||
|
condition: service_healthy
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8003:8000
|
||||||
|
environment:
|
||||||
|
# Multi-user BasicAuth pass-through mode (ADR-020)
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
|
||||||
|
- ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
|
||||||
|
# Token storage (required for middleware initialization)
|
||||||
|
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
|
||||||
|
# Vector sync disabled (stateless pass-through mode)
|
||||||
|
- VECTOR_SYNC_ENABLED=false
|
||||||
|
|
||||||
|
# NO admin credentials - credentials come from client Authorization header
|
||||||
|
volumes:
|
||||||
|
- multi-user-basic-data:/app/data
|
||||||
|
|
||||||
mcp-oauth:
|
mcp-oauth:
|
||||||
build: .
|
build: .
|
||||||
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"]
|
||||||
@@ -159,6 +185,11 @@ services:
|
|||||||
# Qdrant configuration - persistent local storage
|
# Qdrant configuration - persistent local storage
|
||||||
- QDRANT_LOCATION=/app/data/qdrant
|
- QDRANT_LOCATION=/app/data/qdrant
|
||||||
|
|
||||||
|
# Embedding provider for vector sync (use Simple provider as fallback)
|
||||||
|
# Ollama not available in CI/test environments
|
||||||
|
# - OLLAMA_BASE_URL=http://ollama:11434
|
||||||
|
# - OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
|
||||||
# Client credentials registered via RFC 7591 and stored in volume
|
# Client credentials registered via RFC 7591 and stored in volume
|
||||||
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
# JWT token type is used for testing (faster validation, scopes embedded in token)
|
||||||
@@ -280,3 +311,4 @@ volumes:
|
|||||||
keycloak-oauth-storage:
|
keycloak-oauth-storage:
|
||||||
qdrant-data:
|
qdrant-data:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
|
multi-user-basic-data:
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# ADR-020: Deployment Modes and Configuration Validation
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2025-12-20
|
||||||
|
**Deciders:** Development Team
|
||||||
|
**Related:** ADR-002 (Vector Sync), ADR-004 (Progressive Consent), ADR-019 (Multi-user BasicAuth)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MCP server supports multiple deployment scenarios with different authentication methods, storage backends, and feature sets. Over time, the configuration system evolved to support ~500+ possible combinations across deployment modes, authentication patterns, and feature toggles. This complexity made it difficult to:
|
||||||
|
|
||||||
|
1. Understand what configuration is required for a given deployment
|
||||||
|
2. Debug configuration errors (validation scattered across multiple files)
|
||||||
|
3. Provide helpful error messages when configuration is invalid
|
||||||
|
4. Maintain clear boundaries between deployment modes
|
||||||
|
|
||||||
|
**Problems Identified:**
|
||||||
|
- No single source of truth for "what config is required for mode X"
|
||||||
|
- Validation happening at 4+ different points (Settings.__post_init__, setup_oauth_config(), context helpers, starlette_lifespan)
|
||||||
|
- Startup sequence unclear (OAuth setup before FastMCP creation, sync initialization errors)
|
||||||
|
- Error messages generic ("X is required") without explaining which deployment mode triggered the requirement
|
||||||
|
- Multiple overlapping decision trees (deployment mode, auth mode, features)
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We formalize five distinct deployment modes with explicit configuration requirements and implement centralized configuration validation.
|
||||||
|
|
||||||
|
### Deployment Modes
|
||||||
|
|
||||||
|
#### 1. Single-User BasicAuth
|
||||||
|
|
||||||
|
**Use Case:** Personal Nextcloud instance, local development
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://localhost:8080
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=password # Or app password
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# Vector sync (semantic search)
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
QDRANT_LOCATION=/path/to/qdrant # Or QDRANT_URL for remote
|
||||||
|
|
||||||
|
# Embeddings (optional - Simple provider used as fallback)
|
||||||
|
OLLAMA_BASE_URL=http://localhost:11434
|
||||||
|
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||||
|
|
||||||
|
# Document processing
|
||||||
|
DOCUMENT_CHUNK_SIZE=512
|
||||||
|
DOCUMENT_CHUNK_OVERLAP=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Single shared NextcloudClient created at startup
|
||||||
|
- No OAuth infrastructure needed
|
||||||
|
- No multi-user support
|
||||||
|
- Vector sync runs as single-user background task
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 2. Multi-User BasicAuth Pass-Through
|
||||||
|
|
||||||
|
**Use Case:** Internal deployment where users provide their own credentials, no background sync needed
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_MULTI_USER_BASIC_AUTH=true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# For background sync (requires app passwords from Astrolabe)
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# ... plus Qdrant and embedding config
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conditional Requirements:**
|
||||||
|
- If `ENABLE_OFFLINE_ACCESS=true`: requires `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`, `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
|
||||||
|
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- No OAuth for client authentication (uses BasicAuth in request headers)
|
||||||
|
- BasicAuthMiddleware extracts credentials from Authorization header
|
||||||
|
- Client created per-request from extracted credentials
|
||||||
|
- Optional: Background sync using app passwords (via Astrolabe API)
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. OAuth Single-Audience (Default)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment with OAuth authentication, tokens work for both MCP and Nextcloud
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Auto-Configured:**
|
||||||
|
- OIDC discovery URL: `{NEXTCLOUD_HOST}/.well-known/openid-configuration`
|
||||||
|
- Client credentials: Dynamic Client Registration (DCR) if available
|
||||||
|
- Token storage: SQLite at `~/.oauth/clients.db`
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
```bash
|
||||||
|
# Static client credentials (instead of DCR)
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_ID=<client-id>
|
||||||
|
NEXTCLOUD_OIDC_CLIENT_SECRET=<client-secret>
|
||||||
|
|
||||||
|
# Offline access for background sync
|
||||||
|
ENABLE_OFFLINE_ACCESS=true
|
||||||
|
TOKEN_ENCRYPTION_KEY=<key>
|
||||||
|
TOKEN_STORAGE_DB=/path/to/tokens.db
|
||||||
|
VECTOR_SYNC_ENABLED=true
|
||||||
|
# ... plus Qdrant and embedding config
|
||||||
|
|
||||||
|
# Scopes
|
||||||
|
NEXTCLOUD_OIDC_SCOPES="openid profile email notes:read notes:write ..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Conditional Requirements:**
|
||||||
|
- If `ENABLE_OFFLINE_ACCESS=true`: requires `TOKEN_ENCRYPTION_KEY`, `TOKEN_STORAGE_DB`
|
||||||
|
- If `VECTOR_SYNC_ENABLED=true`: requires `ENABLE_OFFLINE_ACCESS=true`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Tokens contain both `aud: ["mcp-server", "nextcloud"]`
|
||||||
|
- Pass token through to Nextcloud APIs (no exchange)
|
||||||
|
- Client created per-request from token in Authorization header
|
||||||
|
- Background sync uses refresh tokens (if offline_access enabled)
|
||||||
|
- Admin UI available at /app
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 4. OAuth Token Exchange (RFC 8693)
|
||||||
|
|
||||||
|
**Use Case:** Multi-user deployment where MCP token is separate from Nextcloud token
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
```bash
|
||||||
|
NEXTCLOUD_HOST=http://nextcloud.example.com
|
||||||
|
ENABLE_TOKEN_EXCHANGE=true
|
||||||
|
# No NEXTCLOUD_USERNAME/PASSWORD (triggers OAuth mode)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Optional Configuration:**
|
||||||
|
- Same as OAuth Single-Audience, plus:
|
||||||
|
```bash
|
||||||
|
TOKEN_EXCHANGE_CACHE_TTL=300 # Cache exchanged tokens
|
||||||
|
```
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- Tokens contain only `aud: "mcp-server"`
|
||||||
|
- MCP server exchanges token for Nextcloud token via RFC 8693
|
||||||
|
- Exchanged tokens cached per-user
|
||||||
|
- Client created per-request using exchanged token
|
||||||
|
- Background sync uses refresh tokens (if offline_access enabled)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 5. Smithery Stateless
|
||||||
|
|
||||||
|
**Use Case:** Multi-tenant SaaS deployment via Smithery platform
|
||||||
|
|
||||||
|
**Required Configuration:**
|
||||||
|
- None! Configuration comes from session URL params: `?nextcloud_url=...&username=...&app_password=...`
|
||||||
|
|
||||||
|
**Forbidden Configuration:**
|
||||||
|
- Must NOT set: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD`, `ENABLE_MULTI_USER_BASIC_AUTH`, `ENABLE_TOKEN_EXCHANGE`, `ENABLE_OFFLINE_ACCESS`, `VECTOR_SYNC_ENABLED`, `NEXTCLOUD_OIDC_CLIENT_ID`, `NEXTCLOUD_OIDC_CLIENT_SECRET`
|
||||||
|
|
||||||
|
**Characteristics:**
|
||||||
|
- No persistent storage (stateless)
|
||||||
|
- Client created per-request from session config
|
||||||
|
- No vector sync (disabled)
|
||||||
|
- No admin UI (no /app routes)
|
||||||
|
- No OAuth infrastructure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Configuration Validation
|
||||||
|
|
||||||
|
**Implementation:** `nextcloud_mcp_server/config_validators.py`
|
||||||
|
|
||||||
|
**Key Functions:**
|
||||||
|
```python
|
||||||
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||||
|
"""Detect authentication mode from configuration.
|
||||||
|
|
||||||
|
Priority (most specific to most general):
|
||||||
|
1. Smithery (explicit flag)
|
||||||
|
2. Token exchange (most specific OAuth mode)
|
||||||
|
3. Multi-user BasicAuth
|
||||||
|
4. Single-user BasicAuth
|
||||||
|
5. OAuth single-audience (default OAuth mode)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
||||||
|
"""Validate configuration for detected mode.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (detected_mode, list_of_errors)
|
||||||
|
Empty list means valid configuration.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
**Validation Rules:**
|
||||||
|
- **Required variables:** Must be set and non-empty
|
||||||
|
- **Forbidden variables:** Must NOT be set (or must be False for booleans)
|
||||||
|
- **Conditional requirements:** If feature X is enabled, requires variables Y and Z
|
||||||
|
|
||||||
|
**Error Messages:**
|
||||||
|
```
|
||||||
|
Configuration validation failed for {mode} mode:
|
||||||
|
- [{mode}] Missing required configuration: NEXTCLOUD_HOST
|
||||||
|
- [{mode}] ENABLE_OFFLINE_ACCESS must be enabled when VECTOR_SYNC_ENABLED is true
|
||||||
|
|
||||||
|
Mode: {mode}
|
||||||
|
Description: {mode_description}
|
||||||
|
|
||||||
|
Required configuration:
|
||||||
|
- VAR1
|
||||||
|
- VAR2
|
||||||
|
|
||||||
|
Optional configuration:
|
||||||
|
- VAR3
|
||||||
|
- VAR4
|
||||||
|
|
||||||
|
Conditional requirements:
|
||||||
|
When FEATURE is enabled:
|
||||||
|
- VAR5
|
||||||
|
- VAR6
|
||||||
|
```
|
||||||
|
|
||||||
|
**Integration:**
|
||||||
|
- Validation runs at app startup in `get_app()` (app.py:1048-1062)
|
||||||
|
- All errors reported before any initialization begins
|
||||||
|
- Mode-specific error messages explain requirements
|
||||||
|
- Validation uses the same Settings object used throughout the app
|
||||||
|
|
||||||
|
### Configuration Matrix
|
||||||
|
|
||||||
|
| Variable | Single BasicAuth | Multi BasicAuth | OAuth Single | OAuth Exchange | Smithery |
|
||||||
|
|----------|------------------|-----------------|--------------|----------------|----------|
|
||||||
|
| **NEXTCLOUD_HOST** | Required | Required | Required | Required | Forbidden |
|
||||||
|
| **NEXTCLOUD_USERNAME** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **NEXTCLOUD_PASSWORD** | Required | Forbidden | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **ENABLE_MULTI_USER_BASIC_AUTH** | Forbidden | Required | Forbidden | Forbidden | Forbidden |
|
||||||
|
| **ENABLE_TOKEN_EXCHANGE** | Forbidden | Forbidden | Forbidden | Required | Forbidden |
|
||||||
|
| **ENABLE_OFFLINE_ACCESS** | Optional\* | Optional\* | Optional\* | Optional\* | Forbidden |
|
||||||
|
| **TOKEN_ENCRYPTION_KEY** | If offline | If offline | If offline | If offline | Forbidden |
|
||||||
|
| **TOKEN_STORAGE_DB** | If offline | If offline | If offline | If offline | Forbidden |
|
||||||
|
| **OIDC_CLIENT_ID** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
|
||||||
|
| **OIDC_CLIENT_SECRET** | Forbidden | If offline | Optional\*\* | Optional\*\* | Forbidden |
|
||||||
|
| **VECTOR_SYNC_ENABLED** | Optional | Optional | Optional | Optional | Forbidden |
|
||||||
|
| **QDRANT_URL/LOCATION** | If vector | If vector | If vector | If vector | Forbidden |
|
||||||
|
| **OLLAMA_BASE_URL/OPENAI_API_KEY** | Optional | Optional | Optional | Optional | Forbidden |
|
||||||
|
|
||||||
|
\* Only enables background sync for semantic search
|
||||||
|
\*\* Uses DCR if not provided
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
1. **Clarity:** Single function to detect mode from config
|
||||||
|
2. **Validation:** All config validated upfront with helpful errors
|
||||||
|
3. **Debugging:** Clear logs showing "Running in X mode with config Y"
|
||||||
|
4. **Maintenance:** Mode-specific logic can be isolated
|
||||||
|
5. **Documentation:** Clear mapping of mode → required config
|
||||||
|
6. **Error Messages:** Context-aware ("X is required for Y mode")
|
||||||
|
7. **Testing:** Each mode testable in isolation
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
1. **Migration:** Existing invalid configurations will now fail at startup
|
||||||
|
2. **Flexibility:** Less flexibility in configuration combinations
|
||||||
|
3. **Strictness:** Some previously-working combinations may be rejected
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
1. **Backward Compatibility:** Valid configurations continue to work
|
||||||
|
2. **Mode Detection:** Automatic based on config (no explicit mode selection)
|
||||||
|
3. **Default Mode:** OAuth single-audience when no credentials provided
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Embedding Provider Validation
|
||||||
|
|
||||||
|
Originally, validation required either `OLLAMA_BASE_URL` or `OPENAI_API_KEY` when vector sync was enabled. This was too strict because the Simple provider is always available as a fallback (ADR-015). The validation was removed to allow vector sync without explicit provider configuration.
|
||||||
|
|
||||||
|
### Variable Scoping Issues
|
||||||
|
|
||||||
|
During implementation, several Python variable scoping issues were discovered in `app.py`:
|
||||||
|
- Local variable assignments in `starlette_lifespan()` shadowed outer scope variables
|
||||||
|
- Fixed by using unique variable names (e.g., `nextcloud_host_for_context`, `basic_auth_storage`)
|
||||||
|
- Removed redundant `settings = get_settings()` call (re-used outer scope)
|
||||||
|
|
||||||
|
### Docker Compose Configuration
|
||||||
|
|
||||||
|
The `mcp-oauth` service configuration was updated to remove `ENABLE_MULTI_USER_BASIC_AUTH=true` which conflicted with its intended OAuth mode. The service now runs in OAuth single-audience mode with vector sync using the Simple embedding provider as fallback.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
|
||||||
|
`tests/unit/test_config_validators.py` provides comprehensive coverage:
|
||||||
|
- Mode detection with priority ordering (7 tests)
|
||||||
|
- Single-user BasicAuth validation (8 tests)
|
||||||
|
- Multi-user BasicAuth validation (7 tests)
|
||||||
|
- OAuth single-audience validation (6 tests)
|
||||||
|
- OAuth token exchange validation (3 tests)
|
||||||
|
- Smithery validation (4 tests)
|
||||||
|
- Mode summary generation (3 tests)
|
||||||
|
- Edge cases (3 tests)
|
||||||
|
|
||||||
|
**Total: 41 tests, all passing**
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
|
||||||
|
Integration tests verify that:
|
||||||
|
- Each mode starts successfully with valid configuration
|
||||||
|
- Invalid configurations fail with clear error messages
|
||||||
|
- Existing deployments continue to work
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [ADR-002: Vector Sync Authentication](ADR-002-vector-sync-authentication.md)
|
||||||
|
- [ADR-004: Progressive Consent](ADR-004-progressive-consent.md)
|
||||||
|
- [ADR-015: Unified Provider Architecture](ADR-015-unified-provider-architecture.md)
|
||||||
|
- [ADR-019: Multi-user BasicAuth Pass-Through](ADR-019-multi-user-basicauth-passthrough.md)
|
||||||
|
- Implementation: `nextcloud_mcp_server/config_validators.py`
|
||||||
|
- Tests: `tests/unit/test_config_validators.py`
|
||||||
+171
-88
@@ -41,10 +41,14 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
|||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
get_deployment_mode,
|
|
||||||
get_document_processor_config,
|
get_document_processor_config,
|
||||||
get_settings,
|
get_settings,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.config_validators import (
|
||||||
|
AuthMode,
|
||||||
|
get_mode_summary,
|
||||||
|
validate_configuration,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.observability import (
|
from nextcloud_mcp_server.observability import (
|
||||||
@@ -351,6 +355,52 @@ def get_smithery_session_config() -> dict | None:
|
|||||||
return _smithery_session_config.get()
|
return _smithery_session_config.get()
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthMiddleware:
|
||||||
|
"""Middleware to extract BasicAuth credentials from Authorization header.
|
||||||
|
|
||||||
|
For multi-user BasicAuth pass-through mode, this middleware extracts
|
||||||
|
username/password from the Authorization: Basic header and stores them
|
||||||
|
in the request state for use by the context layer.
|
||||||
|
|
||||||
|
The credentials are NOT stored persistently - they are passed through
|
||||||
|
directly to Nextcloud APIs for each request (stateless).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp):
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self, scope: StarletteScope, receive: Receive, send: Send
|
||||||
|
) -> None:
|
||||||
|
if scope["type"] == "http":
|
||||||
|
# Extract Authorization header
|
||||||
|
headers = dict(scope.get("headers", []))
|
||||||
|
auth_header = headers.get(b"authorization", b"")
|
||||||
|
|
||||||
|
if auth_header.startswith(b"Basic "):
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Decode base64(username:password)
|
||||||
|
encoded = auth_header[6:] # Skip "Basic "
|
||||||
|
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||||
|
username, password = decoded.split(":", 1)
|
||||||
|
|
||||||
|
# Store in request state
|
||||||
|
scope.setdefault("state", {})
|
||||||
|
scope["state"]["basic_auth"] = {
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
f"BasicAuth credentials extracted for user: {username}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to extract BasicAuth credentials: {e}")
|
||||||
|
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
|
||||||
|
|
||||||
class SmitheryConfigMiddleware:
|
class SmitheryConfigMiddleware:
|
||||||
"""Middleware to extract Smithery config from URL query parameters.
|
"""Middleware to extract Smithery config from URL query parameters.
|
||||||
|
|
||||||
@@ -423,41 +473,6 @@ async def app_lifespan_smithery(server: FastMCP) -> AsyncIterator[SmitheryAppCon
|
|||||||
logger.info("Shutting down Smithery stateless mode")
|
logger.info("Shutting down Smithery stateless mode")
|
||||||
|
|
||||||
|
|
||||||
def is_oauth_mode() -> bool:
|
|
||||||
"""
|
|
||||||
Determine if OAuth mode should be used.
|
|
||||||
|
|
||||||
OAuth mode is enabled when:
|
|
||||||
- NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD are NOT set
|
|
||||||
- AND we are NOT in Smithery stateless mode
|
|
||||||
- Or explicitly enabled via configuration
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if OAuth mode, False if BasicAuth mode
|
|
||||||
"""
|
|
||||||
# ADR-016: Smithery stateless mode uses per-request BasicAuth from session config
|
|
||||||
# It's not OAuth mode even though env credentials aren't set
|
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
|
||||||
logger.info(
|
|
||||||
"BasicAuth mode (Smithery stateless - credentials from session config)"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME")
|
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD")
|
|
||||||
|
|
||||||
# If both username and password are set, use BasicAuth
|
|
||||||
if username and password:
|
|
||||||
logger.info(
|
|
||||||
"BasicAuth mode detected (NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD set)"
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.info("OAuth mode detected (NEXTCLOUD_USERNAME/PASSWORD not set)")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
async def load_oauth_client_credentials(
|
async def load_oauth_client_credentials(
|
||||||
nextcloud_host: str, registration_endpoint: str | None
|
nextcloud_host: str, registration_endpoint: str | None
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str]:
|
||||||
@@ -578,17 +593,31 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
"""
|
"""
|
||||||
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
|
||||||
|
|
||||||
Creates a single Nextcloud client with basic authentication
|
For single-user mode: Creates a single Nextcloud client with basic authentication
|
||||||
that is shared across all requests within a session.
|
that is shared across all requests within a session.
|
||||||
|
|
||||||
|
For multi-user mode: No shared client - clients created per-request by BasicAuthMiddleware.
|
||||||
|
|
||||||
Note: Background tasks (scanner, processor) are started at server level
|
Note: Background tasks (scanner, processor) are started at server level
|
||||||
in starlette_lifespan, not here. This lifespan runs per-session.
|
in starlette_lifespan, not here. This lifespan runs per-session.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting MCP session in BasicAuth mode")
|
settings = get_settings()
|
||||||
logger.info("Creating Nextcloud client with BasicAuth")
|
is_multi_user = settings.enable_multi_user_basic_auth
|
||||||
|
|
||||||
client = NextcloudClient.from_env()
|
logger.info(
|
||||||
logger.info("Client initialization complete")
|
f"Starting MCP session in {'multi-user' if is_multi_user else 'single-user'} BasicAuth mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only create shared client for single-user mode
|
||||||
|
client = None
|
||||||
|
if not is_multi_user:
|
||||||
|
logger.info("Creating shared Nextcloud client with BasicAuth")
|
||||||
|
client = NextcloudClient.from_env()
|
||||||
|
logger.info("Client initialization complete")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Multi-user mode - clients created per-request from BasicAuth headers"
|
||||||
|
)
|
||||||
|
|
||||||
# Initialize persistent storage (for webhook tracking and future features)
|
# Initialize persistent storage (for webhook tracking and future features)
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
@@ -604,7 +633,7 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
# Include vector sync state from module singleton (set by starlette_lifespan)
|
# Include vector sync state from module singleton (set by starlette_lifespan)
|
||||||
try:
|
try:
|
||||||
yield AppContext(
|
yield AppContext(
|
||||||
client=client,
|
client=client, # type: ignore[arg-type] # None in multi-user mode
|
||||||
storage=storage,
|
storage=storage,
|
||||||
document_send_stream=_vector_sync_state.document_send_stream,
|
document_send_stream=_vector_sync_state.document_send_stream,
|
||||||
document_receive_stream=_vector_sync_state.document_receive_stream,
|
document_receive_stream=_vector_sync_state.document_receive_stream,
|
||||||
@@ -613,7 +642,8 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
logger.info("Shutting down BasicAuth session")
|
logger.info("Shutting down BasicAuth session")
|
||||||
await client.close()
|
if client is not None:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
async def setup_oauth_config():
|
async def setup_oauth_config():
|
||||||
@@ -985,6 +1015,33 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Initialize observability (logging will be configured by uvicorn)
|
# Initialize observability (logging will be configured by uvicorn)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Validate configuration and detect deployment mode
|
||||||
|
mode, config_errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
if config_errors:
|
||||||
|
error_msg = (
|
||||||
|
f"Configuration validation failed for {mode.value} mode:\n"
|
||||||
|
+ "\n".join(f" - {err}" for err in config_errors)
|
||||||
|
+ "\n\n"
|
||||||
|
+ get_mode_summary(mode)
|
||||||
|
)
|
||||||
|
logger.error(error_msg)
|
||||||
|
raise ValueError(error_msg)
|
||||||
|
|
||||||
|
logger.info(f"✅ Configuration validated successfully for {mode.value} mode")
|
||||||
|
logger.debug(f"Mode details:\n{get_mode_summary(mode)}")
|
||||||
|
|
||||||
|
# Derive helper variables for backward compatibility with existing code
|
||||||
|
oauth_enabled = mode in (
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
|
)
|
||||||
|
deployment_mode = (
|
||||||
|
DeploymentMode.SMITHERY_STATELESS
|
||||||
|
if mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
else DeploymentMode.SELF_HOSTED
|
||||||
|
)
|
||||||
|
|
||||||
# Setup Prometheus metrics (always enabled by default)
|
# Setup Prometheus metrics (always enabled by default)
|
||||||
if settings.metrics_enabled:
|
if settings.metrics_enabled:
|
||||||
setup_metrics(port=settings.metrics_port)
|
setup_metrics(port=settings.metrics_port)
|
||||||
@@ -1008,11 +1065,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
"OpenTelemetry tracing disabled (set OTEL_EXPORTER_OTLP_ENDPOINT to enable)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Determine authentication mode and deployment mode
|
# Create MCP server based on detected mode
|
||||||
oauth_enabled = is_oauth_mode()
|
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
|
|
||||||
if oauth_enabled:
|
|
||||||
logger.info("Configuring MCP server for OAuth mode")
|
logger.info("Configuring MCP server for OAuth mode")
|
||||||
# Asynchronously get the OAuth configuration
|
# Asynchronously get the OAuth configuration
|
||||||
import anyio
|
import anyio
|
||||||
@@ -1075,33 +1129,32 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
enable_dns_rebinding_protection=False
|
enable_dns_rebinding_protection=False
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
elif mode == AuthMode.SMITHERY_STATELESS:
|
||||||
|
logger.info("Configuring MCP server for Smithery stateless mode")
|
||||||
|
# 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
|
||||||
|
),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
# BasicAuth modes (single-user or multi-user)
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
logger.info(f"Configuring MCP server for {mode.value} mode")
|
||||||
logger.info("Configuring MCP server for Smithery stateless mode")
|
mcp = FastMCP(
|
||||||
# json_response=True returns plain JSON-RPC instead of SSE format,
|
"Nextcloud MCP",
|
||||||
# required for Smithery scanner compatibility
|
lifespan=app_lifespan_basic,
|
||||||
mcp = FastMCP(
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
"Nextcloud MCP",
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
lifespan=app_lifespan_smithery,
|
transport_security=TransportSecuritySettings(
|
||||||
json_response=True,
|
enable_dns_rebinding_protection=False
|
||||||
# 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:
|
|
||||||
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.resource("nc://capabilities")
|
@mcp.resource("nc://capabilities")
|
||||||
async def nc_get_capabilities():
|
async def nc_get_capabilities():
|
||||||
@@ -1139,8 +1192,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
# Register semantic search tools (cross-app feature)
|
# Register semantic search tools (cross-app feature)
|
||||||
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
# ADR-016: Skip in Smithery stateless mode (no vector database)
|
||||||
settings = get_settings()
|
|
||||||
deployment_mode = get_deployment_mode()
|
|
||||||
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
if deployment_mode == DeploymentMode.SMITHERY_STATELESS:
|
||||||
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
logger.info("Skipping semantic search tools (Smithery stateless mode)")
|
||||||
elif settings.vector_sync_enabled:
|
elif settings.vector_sync_enabled:
|
||||||
@@ -1227,13 +1278,20 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Set OAuth context for OAuth login routes (ADR-004)
|
# Set OAuth context for OAuth login routes (ADR-004)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
# Prepare OAuth config from setup_oauth_config closure variables
|
# Prepare OAuth config from setup_oauth_config closure variables
|
||||||
|
# Get nextcloud_host from settings (it was validated as required)
|
||||||
|
nextcloud_host_for_context = settings.nextcloud_host
|
||||||
|
if not nextcloud_host_for_context:
|
||||||
|
raise ValueError("NEXTCLOUD_HOST is required for OAuth mode")
|
||||||
|
|
||||||
mcp_server_url = os.getenv(
|
mcp_server_url = os.getenv(
|
||||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||||
)
|
)
|
||||||
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
|
nextcloud_resource_uri = os.getenv(
|
||||||
|
"NEXTCLOUD_RESOURCE_URI", nextcloud_host_for_context
|
||||||
|
)
|
||||||
discovery_url = os.getenv(
|
discovery_url = os.getenv(
|
||||||
"OIDC_DISCOVERY_URL",
|
"OIDC_DISCOVERY_URL",
|
||||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
f"{nextcloud_host_for_context}/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
|
||||||
|
|
||||||
@@ -1247,7 +1305,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
"client_id": client_id, # From setup_oauth_config (DCR or static)
|
||||||
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
|
||||||
"scopes": scopes,
|
"scopes": scopes,
|
||||||
"nextcloud_host": nextcloud_host,
|
"nextcloud_host": nextcloud_host_for_context,
|
||||||
"nextcloud_resource_uri": nextcloud_resource_uri,
|
"nextcloud_resource_uri": nextcloud_resource_uri,
|
||||||
"oauth_provider": oauth_provider,
|
"oauth_provider": oauth_provider,
|
||||||
},
|
},
|
||||||
@@ -1273,16 +1331,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# BasicAuth mode - share storage with browser_app for webhook management
|
# BasicAuth mode - share storage with browser_app for webhook management
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
basic_auth_storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await basic_auth_storage.initialize()
|
||||||
|
|
||||||
app.state.storage = storage
|
app.state.storage = basic_auth_storage
|
||||||
|
|
||||||
# Also share with browser_app for webhook routes
|
# Also share with browser_app for webhook routes
|
||||||
for route in app.routes:
|
for route in app.routes:
|
||||||
if isinstance(route, Mount) and route.path == "/app":
|
if isinstance(route, Mount) and route.path == "/app":
|
||||||
browser_app = cast(Starlette, route.app)
|
browser_app = cast(Starlette, route.app)
|
||||||
browser_app.state.storage = storage
|
browser_app.state.storage = basic_auth_storage
|
||||||
logger.info(
|
logger.info(
|
||||||
"Storage shared with browser_app for webhook management"
|
"Storage shared with browser_app for webhook management"
|
||||||
)
|
)
|
||||||
@@ -1292,7 +1350,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Scanner runs at server-level (once), not per-session
|
# Scanner runs at server-level (once), not per-session
|
||||||
import anyio as anyio_module
|
import anyio as anyio_module
|
||||||
|
|
||||||
settings = get_settings()
|
# Re-use settings from outer scope (already validated)
|
||||||
|
|
||||||
# Check if vector sync is enabled and determine the mode
|
# Check if vector sync is enabled and determine the mode
|
||||||
enable_offline_access_for_sync = os.getenv(
|
enable_offline_access_for_sync = os.getenv(
|
||||||
@@ -1300,7 +1358,13 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
).lower() in ("true", "1", "yes")
|
).lower() in ("true", "1", "yes")
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
|
|
||||||
if settings.vector_sync_enabled and not oauth_enabled:
|
# Multi-user BasicAuth uses OAuth-style background sync (with app passwords)
|
||||||
|
# So skip single-user BasicAuth vector sync if in multi-user mode
|
||||||
|
if (
|
||||||
|
settings.vector_sync_enabled
|
||||||
|
and not oauth_enabled
|
||||||
|
and not settings.enable_multi_user_basic_auth
|
||||||
|
):
|
||||||
# BasicAuth mode - single user sync
|
# BasicAuth mode - single user sync
|
||||||
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
logger.info("Starting background vector sync tasks for BasicAuth mode")
|
||||||
|
|
||||||
@@ -1400,13 +1464,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
elif (
|
elif (
|
||||||
settings.vector_sync_enabled
|
settings.vector_sync_enabled
|
||||||
and oauth_enabled
|
and (oauth_enabled or settings.enable_multi_user_basic_auth)
|
||||||
and enable_offline_access_for_sync
|
and enable_offline_access_for_sync
|
||||||
and refresh_token_storage
|
and refresh_token_storage
|
||||||
and encryption_key
|
and encryption_key
|
||||||
):
|
):
|
||||||
# OAuth mode with offline access - multi-user sync
|
# OAuth mode with offline access - multi-user sync
|
||||||
logger.info("Starting background vector sync tasks for OAuth mode")
|
# Also used for multi-user BasicAuth mode (client auth is BasicAuth, background sync uses app passwords)
|
||||||
|
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
||||||
|
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
@@ -1414,10 +1480,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
user_manager_task,
|
user_manager_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Get nextcloud_host (from settings - already validated)
|
||||||
|
nextcloud_host_for_sync = settings.nextcloud_host
|
||||||
|
if not nextcloud_host_for_sync:
|
||||||
|
raise ValueError("NEXTCLOUD_HOST required for vector sync")
|
||||||
|
|
||||||
# Get OIDC discovery URL (same as used for OAuth setup)
|
# Get OIDC discovery URL (same as used for OAuth setup)
|
||||||
discovery_url = os.getenv(
|
discovery_url = os.getenv(
|
||||||
"OIDC_DISCOVERY_URL",
|
"OIDC_DISCOVERY_URL",
|
||||||
f"{nextcloud_host}/.well-known/openid-configuration",
|
f"{nextcloud_host_for_sync}/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get client credentials from oauth_context (set by setup_oauth_config)
|
# Get client credentials from oauth_context (set by setup_oauth_config)
|
||||||
@@ -1428,6 +1499,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
sync_client_id = oauth_config.get("client_id")
|
sync_client_id = oauth_config.get("client_id")
|
||||||
sync_client_secret = oauth_config.get("client_secret")
|
sync_client_secret = oauth_config.get("client_secret")
|
||||||
|
|
||||||
|
# For multi-user BasicAuth mode, get OIDC credentials from environment
|
||||||
|
if not sync_client_id or not sync_client_secret:
|
||||||
|
sync_client_id = settings.oidc_client_id
|
||||||
|
sync_client_secret = settings.oidc_client_secret
|
||||||
|
|
||||||
if not sync_client_id or not sync_client_secret:
|
if not sync_client_id or not sync_client_secret:
|
||||||
logger.error(
|
logger.error(
|
||||||
"Cannot start OAuth vector sync: client credentials not found in oauth_context"
|
"Cannot start OAuth vector sync: client credentials not found in oauth_context"
|
||||||
@@ -2141,4 +2217,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
app = SmitheryConfigMiddleware(app)
|
app = SmitheryConfigMiddleware(app)
|
||||||
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
logger.info("SmitheryConfigMiddleware enabled for query parameter config")
|
||||||
|
|
||||||
|
# Apply BasicAuthMiddleware for multi-user BasicAuth pass-through mode
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
app = BasicAuthMiddleware(app)
|
||||||
|
logger.info(
|
||||||
|
"BasicAuthMiddleware enabled - multi-user BasicAuth pass-through mode active"
|
||||||
|
)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""
|
||||||
|
Client for querying Astrolabe Management API for background sync credentials.
|
||||||
|
|
||||||
|
This client uses OAuth client credentials flow to authenticate to Nextcloud
|
||||||
|
and retrieve user app passwords for background sync operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AstrolabeClient:
|
||||||
|
"""Client for querying Astrolabe API for background sync credentials.
|
||||||
|
|
||||||
|
Uses OAuth client credentials flow to authenticate as the MCP server
|
||||||
|
and retrieve user app passwords that are stored in Nextcloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
nextcloud_host: str,
|
||||||
|
client_id: str,
|
||||||
|
client_secret: str,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize Astrolabe client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Nextcloud base URL (e.g., https://cloud.example.com)
|
||||||
|
client_id: OAuth client ID for MCP server
|
||||||
|
client_secret: OAuth client secret
|
||||||
|
"""
|
||||||
|
self.nextcloud_host = nextcloud_host.rstrip("/")
|
||||||
|
self.client_id = client_id
|
||||||
|
self.client_secret = client_secret
|
||||||
|
self._token_cache: Optional[dict] = None # {access_token, expires_at}
|
||||||
|
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
"""
|
||||||
|
Get access token using OAuth client credentials flow.
|
||||||
|
|
||||||
|
Tokens are cached with 1-minute early refresh to avoid expiration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Access token string
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If token request fails
|
||||||
|
"""
|
||||||
|
# Check cache
|
||||||
|
if self._token_cache and time.time() < self._token_cache["expires_at"]:
|
||||||
|
logger.debug("Using cached OAuth token for Astrolabe API")
|
||||||
|
return self._token_cache["access_token"]
|
||||||
|
|
||||||
|
# Discover token endpoint
|
||||||
|
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||||
|
discovery_resp = await client.get(discovery_url)
|
||||||
|
discovery_resp.raise_for_status()
|
||||||
|
token_endpoint = discovery_resp.json()["token_endpoint"]
|
||||||
|
|
||||||
|
logger.debug(f"Requesting client credentials token from {token_endpoint}")
|
||||||
|
|
||||||
|
# Request token using client credentials grant
|
||||||
|
token_resp = await client.post(
|
||||||
|
token_endpoint,
|
||||||
|
data={
|
||||||
|
"grant_type": "client_credentials",
|
||||||
|
"client_id": self.client_id,
|
||||||
|
"client_secret": self.client_secret,
|
||||||
|
"scope": "openid", # Minimal scope
|
||||||
|
},
|
||||||
|
)
|
||||||
|
token_resp.raise_for_status()
|
||||||
|
data = token_resp.json()
|
||||||
|
|
||||||
|
# Cache with 1-minute early refresh
|
||||||
|
expires_in = data.get("expires_in", 3600)
|
||||||
|
self._token_cache = {
|
||||||
|
"access_token": data["access_token"],
|
||||||
|
"expires_at": time.time() + expires_in - 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Obtained Astrolabe API token (expires in {expires_in}s)")
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
async def get_user_app_password(self, user_id: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Retrieve user's app password for background sync.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
App password string, or None if user hasn't provisioned
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If API request fails (except 404)
|
||||||
|
"""
|
||||||
|
token = await self.get_access_token()
|
||||||
|
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
logger.debug(f"No app password configured for user: {user_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Retrieved app password for user: {user_id} (type: {data.get('credential_type')})"
|
||||||
|
)
|
||||||
|
return data.get("app_password")
|
||||||
|
|
||||||
|
async def get_background_sync_status(self, user_id: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get background sync status for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Nextcloud user ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: has_access, credential_type, provisioned_at
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If API request fails
|
||||||
|
"""
|
||||||
|
# For now, check if app password exists
|
||||||
|
# In the future, this could query a dedicated status endpoint
|
||||||
|
app_password = await self.get_user_app_password(user_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"has_access": app_password is not None,
|
||||||
|
"credential_type": "app_password" if app_password else None,
|
||||||
|
"provisioned_at": None, # TODO: Get from API if available
|
||||||
|
}
|
||||||
@@ -187,6 +187,11 @@ class Settings:
|
|||||||
enable_token_exchange: bool = False
|
enable_token_exchange: bool = False
|
||||||
enable_offline_access: bool = False
|
enable_offline_access: bool = False
|
||||||
|
|
||||||
|
# Multi-user BasicAuth pass-through mode (ADR-019 interim solution)
|
||||||
|
# When enabled, MCP server extracts BasicAuth credentials from request headers
|
||||||
|
# and passes them through to Nextcloud APIs (no storage, stateless)
|
||||||
|
enable_multi_user_basic_auth: bool = False
|
||||||
|
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||||
|
|
||||||
@@ -376,6 +381,10 @@ def get_settings() -> Settings:
|
|||||||
enable_offline_access=(
|
enable_offline_access=(
|
||||||
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() == "true"
|
||||||
),
|
),
|
||||||
|
# Multi-user BasicAuth pass-through mode
|
||||||
|
enable_multi_user_basic_auth=(
|
||||||
|
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
|
||||||
|
),
|
||||||
# Token exchange cache settings
|
# Token exchange cache settings
|
||||||
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
|
||||||
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
# Token and webhook storage settings (encryption key optional for webhook-only usage)
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
"""Configuration validation and mode detection for the MCP server.
|
||||||
|
|
||||||
|
This module provides:
|
||||||
|
- Mode detection based on configuration
|
||||||
|
- Configuration validation with clear error messages
|
||||||
|
- Single source of truth for deployment mode requirements
|
||||||
|
|
||||||
|
See ADR-020 for detailed architecture and deployment mode documentation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import Settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthMode(Enum):
|
||||||
|
"""Authentication mode for the MCP server.
|
||||||
|
|
||||||
|
Determines how users authenticate and how the server accesses Nextcloud.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SINGLE_USER_BASIC = "single_user_basic"
|
||||||
|
MULTI_USER_BASIC = "multi_user_basic"
|
||||||
|
OAUTH_SINGLE_AUDIENCE = "oauth_single"
|
||||||
|
OAUTH_TOKEN_EXCHANGE = "oauth_exchange"
|
||||||
|
SMITHERY_STATELESS = "smithery"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModeRequirements:
|
||||||
|
"""Requirements for a deployment mode.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
required: Configuration variables that must be set
|
||||||
|
optional: Configuration variables that may be set
|
||||||
|
forbidden: Configuration variables that should not be set
|
||||||
|
conditional: Additional requirements based on feature flags
|
||||||
|
Format: {feature_flag: [required_vars]}
|
||||||
|
description: Human-readable description of the mode
|
||||||
|
"""
|
||||||
|
|
||||||
|
required: list[str]
|
||||||
|
optional: list[str]
|
||||||
|
forbidden: list[str]
|
||||||
|
conditional: dict[str, list[str]]
|
||||||
|
description: str
|
||||||
|
|
||||||
|
|
||||||
|
# Mode requirements definition
|
||||||
|
MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = {
|
||||||
|
AuthMode.SINGLE_USER_BASIC: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "nextcloud_username", "nextcloud_password"],
|
||||||
|
optional=[
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
"document_chunk_size",
|
||||||
|
"document_chunk_overlap",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"vector_sync_enabled": [
|
||||||
|
# Either qdrant_url OR qdrant_location (checked in Settings.__post_init__)
|
||||||
|
# At least one embedding provider (ollama_base_url OR openai_api_key)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description="Single-user deployment with BasicAuth credentials. "
|
||||||
|
"Suitable for personal Nextcloud instances and local development.",
|
||||||
|
),
|
||||||
|
AuthMode.MULTI_USER_BASIC: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "enable_multi_user_basic_auth"],
|
||||||
|
optional=[
|
||||||
|
# Background sync with app passwords (via Astrolabe)
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_token_exchange",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
"vector_sync_enabled": [
|
||||||
|
# Requires offline access for background sync
|
||||||
|
"enable_offline_access",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description="Multi-user deployment with BasicAuth pass-through. "
|
||||||
|
"Users provide credentials in request headers. "
|
||||||
|
"Optional background sync using app passwords stored via Astrolabe.",
|
||||||
|
),
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE: ModeRequirements(
|
||||||
|
required=["nextcloud_host"],
|
||||||
|
optional=[
|
||||||
|
# OAuth credentials (uses DCR if not provided)
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
"oidc_discovery_url",
|
||||||
|
# Offline access
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
# Scopes
|
||||||
|
"nextcloud_oidc_scopes",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
"vector_sync_enabled": [
|
||||||
|
"enable_offline_access", # Background sync requires refresh tokens
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description="OAuth multi-user deployment with single-audience tokens. "
|
||||||
|
"Tokens work for both MCP server and Nextcloud APIs (pass-through). "
|
||||||
|
"Uses Dynamic Client Registration if credentials not provided.",
|
||||||
|
),
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements(
|
||||||
|
required=["nextcloud_host", "enable_token_exchange"],
|
||||||
|
optional=[
|
||||||
|
# OAuth credentials
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
"oidc_discovery_url",
|
||||||
|
# Token exchange settings
|
||||||
|
"token_exchange_cache_ttl",
|
||||||
|
# Offline access
|
||||||
|
"enable_offline_access",
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
# Vector sync
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"qdrant_url",
|
||||||
|
"qdrant_location",
|
||||||
|
"ollama_base_url",
|
||||||
|
"ollama_embedding_model",
|
||||||
|
"openai_api_key",
|
||||||
|
"openai_embedding_model",
|
||||||
|
],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
],
|
||||||
|
conditional={
|
||||||
|
"enable_offline_access": [
|
||||||
|
"token_encryption_key",
|
||||||
|
"token_storage_db",
|
||||||
|
],
|
||||||
|
"vector_sync_enabled": [
|
||||||
|
"enable_offline_access",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
description="OAuth multi-user deployment with token exchange (RFC 8693). "
|
||||||
|
"MCP tokens are separate from Nextcloud tokens. "
|
||||||
|
"Server exchanges MCP token for Nextcloud token on each request.",
|
||||||
|
),
|
||||||
|
AuthMode.SMITHERY_STATELESS: ModeRequirements(
|
||||||
|
required=[], # All config from session URL params
|
||||||
|
optional=[],
|
||||||
|
forbidden=[
|
||||||
|
"nextcloud_host",
|
||||||
|
"nextcloud_username",
|
||||||
|
"nextcloud_password",
|
||||||
|
"enable_multi_user_basic_auth",
|
||||||
|
"enable_token_exchange",
|
||||||
|
"enable_offline_access",
|
||||||
|
"vector_sync_enabled",
|
||||||
|
"oidc_client_id",
|
||||||
|
"oidc_client_secret",
|
||||||
|
],
|
||||||
|
conditional={},
|
||||||
|
description="Stateless multi-tenant deployment for Smithery platform. "
|
||||||
|
"Configuration comes from session URL parameters. "
|
||||||
|
"No persistent storage, no OAuth, no vector sync.",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def detect_auth_mode(settings: Settings) -> AuthMode:
|
||||||
|
"""Detect authentication mode from configuration.
|
||||||
|
|
||||||
|
Mode detection priority (most specific to most general):
|
||||||
|
1. Smithery (explicit flag)
|
||||||
|
2. Token exchange (most specific OAuth mode)
|
||||||
|
3. Multi-user BasicAuth
|
||||||
|
4. Single-user BasicAuth
|
||||||
|
5. OAuth single-audience (default OAuth mode)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Detected AuthMode
|
||||||
|
"""
|
||||||
|
# Check for Smithery mode (explicit environment variable)
|
||||||
|
# Note: This checks the environment directly, not settings
|
||||||
|
# because Smithery mode has no settings-based config
|
||||||
|
import os
|
||||||
|
|
||||||
|
if os.getenv("SMITHERY_DEPLOYMENT", "false").lower() == "true":
|
||||||
|
return AuthMode.SMITHERY_STATELESS
|
||||||
|
|
||||||
|
# Check for token exchange (most specific OAuth mode)
|
||||||
|
if settings.enable_token_exchange:
|
||||||
|
return AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|
||||||
|
# Check for multi-user BasicAuth
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
return AuthMode.MULTI_USER_BASIC
|
||||||
|
|
||||||
|
# Check for single-user BasicAuth (explicit credentials)
|
||||||
|
if settings.nextcloud_username and settings.nextcloud_password:
|
||||||
|
return AuthMode.SINGLE_USER_BASIC
|
||||||
|
|
||||||
|
# Default: OAuth single-audience mode
|
||||||
|
# This is the safest multi-user mode (no credential storage)
|
||||||
|
return AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
|
||||||
|
|
||||||
|
def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]:
|
||||||
|
"""Validate configuration for detected mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
settings: Application settings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (detected_mode, list_of_errors)
|
||||||
|
Empty list means valid configuration.
|
||||||
|
"""
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
requirements = MODE_REQUIREMENTS[mode]
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
logger.debug(f"Validating configuration for mode: {mode.value}")
|
||||||
|
|
||||||
|
# Check required variables
|
||||||
|
for var in requirements.required:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] Missing required configuration: {var.upper()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check forbidden variables
|
||||||
|
for var in requirements.forbidden:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
# For bools, check if True (forbidden means must be False/unset)
|
||||||
|
# For strings, check if non-empty
|
||||||
|
is_set = False
|
||||||
|
if isinstance(value, bool):
|
||||||
|
is_set = value is True
|
||||||
|
elif isinstance(value, str):
|
||||||
|
is_set = bool(value.strip())
|
||||||
|
elif value is not None:
|
||||||
|
is_set = True
|
||||||
|
|
||||||
|
if is_set:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] Forbidden configuration: {var.upper()} "
|
||||||
|
f"should not be set in this mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check conditional requirements
|
||||||
|
for condition, required_vars in requirements.conditional.items():
|
||||||
|
# Check if the condition is enabled
|
||||||
|
condition_value = getattr(settings, condition, None)
|
||||||
|
is_enabled = False
|
||||||
|
|
||||||
|
if isinstance(condition_value, bool):
|
||||||
|
is_enabled = condition_value is True
|
||||||
|
elif isinstance(condition_value, str):
|
||||||
|
is_enabled = bool(condition_value.strip())
|
||||||
|
elif condition_value is not None:
|
||||||
|
is_enabled = True
|
||||||
|
|
||||||
|
if is_enabled:
|
||||||
|
# Check that all required vars for this condition are set
|
||||||
|
for var in required_vars:
|
||||||
|
value = getattr(settings, var, None)
|
||||||
|
|
||||||
|
# For boolean requirements, check that they are True (not just set)
|
||||||
|
if hasattr(Settings, var):
|
||||||
|
field_type = type(getattr(Settings(), var, None))
|
||||||
|
if field_type is bool:
|
||||||
|
if value is not True:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] {var.upper()} must be enabled when "
|
||||||
|
f"{condition.upper()} is enabled"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# For non-boolean requirements, check that they are set
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] {var.upper()} is required when "
|
||||||
|
f"{condition.upper()} is enabled"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Special validations for specific modes
|
||||||
|
if mode == AuthMode.SINGLE_USER_BASIC:
|
||||||
|
# Validate that NEXTCLOUD_HOST doesn't have trailing slash
|
||||||
|
if settings.nextcloud_host and settings.nextcloud_host.endswith("/"):
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] NEXTCLOUD_HOST should not have trailing slash: "
|
||||||
|
f"{settings.nextcloud_host}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode in [
|
||||||
|
AuthMode.OAUTH_SINGLE_AUDIENCE,
|
||||||
|
AuthMode.OAUTH_TOKEN_EXCHANGE,
|
||||||
|
]:
|
||||||
|
# If OAuth credentials not provided, DCR must be available
|
||||||
|
# (This is a runtime check, not a config check, so we just warn)
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
logger.info(
|
||||||
|
f"[{mode.value}] OAuth credentials not configured. "
|
||||||
|
"Will attempt Dynamic Client Registration (DCR) at startup."
|
||||||
|
)
|
||||||
|
|
||||||
|
if mode == AuthMode.MULTI_USER_BASIC:
|
||||||
|
# Validate that if offline access enabled, we have OAuth credentials
|
||||||
|
if settings.enable_offline_access:
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] NEXTCLOUD_OIDC_CLIENT_ID and "
|
||||||
|
"NEXTCLOUD_OIDC_CLIENT_SECRET are required when "
|
||||||
|
"ENABLE_OFFLINE_ACCESS is enabled (for app password retrieval)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate vector sync requirements
|
||||||
|
if settings.vector_sync_enabled and not settings.enable_offline_access:
|
||||||
|
errors.append(
|
||||||
|
f"[{mode.value}] ENABLE_OFFLINE_ACCESS must be enabled when "
|
||||||
|
"VECTOR_SYNC_ENABLED is true (background sync requires "
|
||||||
|
"app passwords or refresh tokens)"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: Embedding provider validation removed - Simple provider is always
|
||||||
|
# available as fallback (ADR-015). Users can optionally configure Ollama or OpenAI
|
||||||
|
# for better quality embeddings.
|
||||||
|
|
||||||
|
return mode, errors
|
||||||
|
|
||||||
|
|
||||||
|
def get_mode_summary(mode: AuthMode) -> str:
|
||||||
|
"""Get human-readable summary of a deployment mode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mode: Deployment mode
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Multi-line string describing the mode
|
||||||
|
"""
|
||||||
|
requirements = MODE_REQUIREMENTS[mode]
|
||||||
|
|
||||||
|
summary_lines = [
|
||||||
|
f"Mode: {mode.value}",
|
||||||
|
f"Description: {requirements.description}",
|
||||||
|
"",
|
||||||
|
"Required configuration:",
|
||||||
|
]
|
||||||
|
|
||||||
|
if requirements.required:
|
||||||
|
for var in requirements.required:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
else:
|
||||||
|
summary_lines.append(" (none - configured via session)")
|
||||||
|
|
||||||
|
summary_lines.append("")
|
||||||
|
summary_lines.append("Optional configuration:")
|
||||||
|
|
||||||
|
if requirements.optional:
|
||||||
|
for var in requirements.optional:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
else:
|
||||||
|
summary_lines.append(" (none)")
|
||||||
|
|
||||||
|
if requirements.conditional:
|
||||||
|
summary_lines.append("")
|
||||||
|
summary_lines.append("Conditional requirements:")
|
||||||
|
for condition, vars in requirements.conditional.items():
|
||||||
|
summary_lines.append(f" When {condition.upper()} is enabled:")
|
||||||
|
for var in vars:
|
||||||
|
summary_lines.append(f" - {var.upper()}")
|
||||||
|
|
||||||
|
return "\n".join(summary_lines)
|
||||||
@@ -67,6 +67,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
return _get_client_from_session_config(ctx)
|
return _get_client_from_session_config(ctx)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Multi-user BasicAuth pass-through mode - extract credentials from request
|
||||||
|
if settings.enable_multi_user_basic_auth:
|
||||||
|
return _get_client_from_basic_auth(ctx)
|
||||||
|
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
lifespan_ctx = ctx.request_context.lifespan_context
|
||||||
|
|
||||||
# BasicAuth mode - use shared client (no token exchange)
|
# BasicAuth mode - use shared client (no token exchange)
|
||||||
@@ -177,3 +182,67 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
username=username,
|
username=username,
|
||||||
auth=BasicAuth(username, app_password),
|
auth=BasicAuth(username, app_password),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||||
|
"""
|
||||||
|
Create NextcloudClient from BasicAuth credentials in request headers.
|
||||||
|
|
||||||
|
For multi-user BasicAuth pass-through mode, this function extracts
|
||||||
|
username/password from the Authorization: Basic header (stored by
|
||||||
|
BasicAuthMiddleware) and creates a client that passes these credentials
|
||||||
|
through to Nextcloud APIs.
|
||||||
|
|
||||||
|
The credentials are NOT stored persistently - they exist only for the
|
||||||
|
duration of this request (stateless).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ctx: MCP request context with basic_auth in request state
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
NextcloudClient configured with BasicAuth credentials
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If BasicAuth credentials not found in request or if
|
||||||
|
NEXTCLOUD_HOST is not configured
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Validate that NEXTCLOUD_HOST is configured
|
||||||
|
if not settings.nextcloud_host:
|
||||||
|
raise ValueError(
|
||||||
|
"NEXTCLOUD_HOST environment variable must be set for multi-user BasicAuth mode"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Extract BasicAuth credentials from request state (set by BasicAuthMiddleware)
|
||||||
|
# Access scope through the request object
|
||||||
|
scope = getattr(ctx.request_context.request, "scope", None)
|
||||||
|
if scope is None:
|
||||||
|
raise ValueError("Request scope not available in context")
|
||||||
|
|
||||||
|
request_state = scope.get("state", {})
|
||||||
|
basic_auth = request_state.get("basic_auth")
|
||||||
|
|
||||||
|
if not basic_auth:
|
||||||
|
raise ValueError(
|
||||||
|
"BasicAuth credentials not found in request. "
|
||||||
|
"Ensure Authorization: Basic header is provided with valid credentials."
|
||||||
|
)
|
||||||
|
|
||||||
|
username = basic_auth.get("username")
|
||||||
|
password = basic_auth.get("password")
|
||||||
|
|
||||||
|
if not username or not password:
|
||||||
|
raise ValueError("Invalid BasicAuth credentials - missing username or password")
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Creating multi-user BasicAuth client for {settings.nextcloud_host} as {username}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create client that passes BasicAuth credentials through to Nextcloud
|
||||||
|
# settings.nextcloud_host is guaranteed to be str after the check above
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=settings.nextcloud_host,
|
||||||
|
username=username,
|
||||||
|
auth=BasicAuth(username, password),
|
||||||
|
)
|
||||||
|
|||||||
@@ -101,6 +101,9 @@ class ProvisioningStatus(BaseModel):
|
|||||||
provisioned_at: Optional[str] = Field(
|
provisioned_at: Optional[str] = Field(
|
||||||
None, description="ISO timestamp when provisioned"
|
None, description="ISO timestamp when provisioned"
|
||||||
)
|
)
|
||||||
|
credential_type: Optional[str] = Field(
|
||||||
|
None, description="Type of credential ('refresh_token' or 'app_password')"
|
||||||
|
)
|
||||||
client_id: Optional[str] = Field(
|
client_id: Optional[str] = Field(
|
||||||
None, description="Client ID that initiated the original Flow 1"
|
None, description="Client ID that initiated the original Flow 1"
|
||||||
)
|
)
|
||||||
@@ -114,8 +117,8 @@ class ProvisioningResult(BaseModel):
|
|||||||
"""Result of provisioning attempt."""
|
"""Result of provisioning attempt."""
|
||||||
|
|
||||||
success: bool = Field(description="Whether provisioning was initiated")
|
success: bool = Field(description="Whether provisioning was initiated")
|
||||||
authorization_url: Optional[str] = Field(
|
provisioning_url: Optional[str] = Field(
|
||||||
None, description="URL for user to complete OAuth authorization"
|
None, description="URL to Astrolabe settings for provisioning background sync"
|
||||||
)
|
)
|
||||||
message: str = Field(description="Status message for the user")
|
message: str = Field(description="Status message for the user")
|
||||||
already_provisioned: bool = Field(
|
already_provisioned: bool = Field(
|
||||||
@@ -143,8 +146,9 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
"""
|
"""
|
||||||
Check the provisioning status for Nextcloud access.
|
Check the provisioning status for Nextcloud access.
|
||||||
|
|
||||||
This checks whether the user has completed Flow 2 to provision
|
Checks for both credential types:
|
||||||
offline access to Nextcloud resources.
|
1. App password from Astrolabe (works today)
|
||||||
|
2. OAuth refresh token from storage (for future)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
mcp: MCP context
|
mcp: MCP context
|
||||||
@@ -153,6 +157,37 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
Returns:
|
Returns:
|
||||||
ProvisioningStatus with current provisioning state
|
ProvisioningStatus with current provisioning state
|
||||||
"""
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Check for app password first (interim solution)
|
||||||
|
if settings.oidc_client_id and settings.oidc_client_secret:
|
||||||
|
try:
|
||||||
|
astrolabe = AstrolabeClient(
|
||||||
|
nextcloud_host=settings.nextcloud_host or "",
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
)
|
||||||
|
status = await astrolabe.get_background_sync_status(user_id)
|
||||||
|
|
||||||
|
if status.get("has_access"):
|
||||||
|
logger.info(
|
||||||
|
f" get_provisioning_status: ✓ App password FOUND for user_id={user_id}"
|
||||||
|
)
|
||||||
|
provisioned_at_str = status.get("provisioned_at")
|
||||||
|
return ProvisioningStatus(
|
||||||
|
is_provisioned=True,
|
||||||
|
provisioned_at=provisioned_at_str,
|
||||||
|
credential_type="app_password",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f" App password check failed for {user_id}: {e}")
|
||||||
|
|
||||||
|
# Check for OAuth refresh token (fallback)
|
||||||
logger.info(
|
logger.info(
|
||||||
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
|
f" get_provisioning_status: Looking up refresh token for user_id={user_id}"
|
||||||
)
|
)
|
||||||
@@ -163,7 +198,7 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
|
|
||||||
if not token_data:
|
if not token_data:
|
||||||
logger.info(
|
logger.info(
|
||||||
f" get_provisioning_status: ✗ No refresh token found for user_id={user_id}"
|
f" get_provisioning_status: ✗ No credentials found for user_id={user_id}"
|
||||||
)
|
)
|
||||||
return ProvisioningStatus(is_provisioned=False)
|
return ProvisioningStatus(is_provisioned=False)
|
||||||
|
|
||||||
@@ -178,14 +213,13 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
# Convert timestamp to ISO format if present
|
# Convert timestamp to ISO format if present
|
||||||
provisioned_at_str = None
|
provisioned_at_str = None
|
||||||
if token_data.get("provisioned_at"):
|
if token_data.get("provisioned_at"):
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
dt = datetime.fromtimestamp(token_data["provisioned_at"], tz=timezone.utc)
|
||||||
provisioned_at_str = dt.isoformat()
|
provisioned_at_str = dt.isoformat()
|
||||||
|
|
||||||
return ProvisioningStatus(
|
return ProvisioningStatus(
|
||||||
is_provisioned=True,
|
is_provisioned=True,
|
||||||
provisioned_at=provisioned_at_str,
|
provisioned_at=provisioned_at_str,
|
||||||
|
credential_type="refresh_token",
|
||||||
client_id=token_data.get("provisioning_client_id"),
|
client_id=token_data.get("provisioning_client_id"),
|
||||||
scopes=token_data.get("scopes"),
|
scopes=token_data.get("scopes"),
|
||||||
flow_type=token_data.get("flow_type", "hybrid"),
|
flow_type=token_data.get("flow_type", "hybrid"),
|
||||||
@@ -239,36 +273,22 @@ async def provision_nextcloud_access(
|
|||||||
"""
|
"""
|
||||||
MCP Tool: Provision offline access to Nextcloud resources.
|
MCP Tool: Provision offline access to Nextcloud resources.
|
||||||
|
|
||||||
This tool initiates Flow 2 of the Progressive Consent architecture,
|
Returns URL to Astrolabe settings page where users can provision background
|
||||||
allowing the MCP server to obtain delegated access to Nextcloud APIs.
|
sync access using either:
|
||||||
|
- App password (works today, interim solution)
|
||||||
The user must complete the OAuth flow in their browser to grant access.
|
- OAuth refresh token (future, when Nextcloud supports OAuth for app APIs)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ctx: MCP context with user's Flow 1 token
|
ctx: MCP context with user's Flow 1 token
|
||||||
user_id: Optional user identifier (extracted from token if not provided)
|
user_id: Optional user identifier (extracted from token if not provided)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ProvisioningResult with authorization URL or status
|
ProvisioningResult with Astrolabe settings URL or status
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Extract user ID from the MCP access token (Flow 1 token)
|
# Extract user ID from the MCP access token (Flow 1 token)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
# Get the authorization token from context
|
user_id = await extract_user_id_from_token(ctx)
|
||||||
if hasattr(ctx, "authorization") and ctx.authorization:
|
|
||||||
token = ctx.authorization.token # type: ignore
|
|
||||||
# Decode token to get user info
|
|
||||||
try:
|
|
||||||
import jwt
|
|
||||||
|
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
|
||||||
user_id = payload.get("sub", "unknown")
|
|
||||||
logger.info(f"Extracted user_id from Flow 1 token: {user_id}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Failed to decode token: {e}")
|
|
||||||
user_id = "default_user"
|
|
||||||
else:
|
|
||||||
user_id = "default_user"
|
|
||||||
|
|
||||||
# Check if already provisioned
|
# Check if already provisioned
|
||||||
status = await get_provisioning_status(ctx, user_id)
|
status = await get_provisioning_status(ctx, user_id)
|
||||||
@@ -277,7 +297,8 @@ async def provision_nextcloud_access(
|
|||||||
success=True,
|
success=True,
|
||||||
already_provisioned=True,
|
already_provisioned=True,
|
||||||
message=(
|
message=(
|
||||||
f"Nextcloud access is already provisioned (since {status.provisioned_at}). "
|
f"Nextcloud access is already provisioned (credential_type={status.credential_type}, "
|
||||||
|
f"since {status.provisioned_at}). "
|
||||||
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
"Use 'revoke_nextcloud_access' if you want to re-provision."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -295,83 +316,20 @@ async def provision_nextcloud_access(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get MCP server's OAuth client credentials
|
# Return Astrolabe settings URL for background sync provisioning
|
||||||
# Try environment variable first, then fall back to DCR client_id
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
server_client_id = os.getenv("MCP_SERVER_CLIENT_ID")
|
astrolabe_url = f"{nextcloud_host}/settings/user/astrolabe#background-sync"
|
||||||
if not server_client_id:
|
|
||||||
# Try to get from lifespan context (DCR)
|
|
||||||
lifespan_ctx = ctx.request_context.lifespan_context
|
|
||||||
if hasattr(lifespan_ctx, "server_client_id"):
|
|
||||||
server_client_id = lifespan_ctx.server_client_id
|
|
||||||
|
|
||||||
if not server_client_id:
|
|
||||||
return ProvisioningResult(
|
|
||||||
success=False,
|
|
||||||
message=(
|
|
||||||
"MCP server OAuth client not configured. "
|
|
||||||
"Set MCP_SERVER_CLIENT_ID environment variable or use Dynamic Client Registration."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate OAuth URL for Flow 2
|
|
||||||
oidc_discovery_url = os.getenv(
|
|
||||||
"OIDC_DISCOVERY_URL",
|
|
||||||
f"{os.getenv('NEXTCLOUD_HOST')}/.well-known/openid-configuration",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate secure state for CSRF protection
|
|
||||||
state = secrets.token_urlsafe(32)
|
|
||||||
|
|
||||||
# Store state in session for validation on callback
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
|
||||||
await storage.initialize()
|
|
||||||
|
|
||||||
# Create OAuth session for Flow 2
|
|
||||||
session_id = f"flow2_{user_id}_{secrets.token_hex(8)}"
|
|
||||||
redirect_uri = f"{os.getenv('NEXTCLOUD_MCP_SERVER_URL', 'http://localhost:8000')}/oauth/callback"
|
|
||||||
|
|
||||||
await storage.store_oauth_session(
|
|
||||||
session_id=session_id,
|
|
||||||
client_redirect_uri="", # No client redirect for Flow 2
|
|
||||||
state=state,
|
|
||||||
flow_type="flow2",
|
|
||||||
is_provisioning=True,
|
|
||||||
ttl_seconds=600, # 10 minute TTL
|
|
||||||
)
|
|
||||||
|
|
||||||
# Define scopes for Nextcloud access
|
|
||||||
scopes = [
|
|
||||||
"openid",
|
|
||||||
"profile",
|
|
||||||
"email",
|
|
||||||
"offline_access", # Critical for background operations
|
|
||||||
"notes:read",
|
|
||||||
"notes:write",
|
|
||||||
"calendar:read",
|
|
||||||
"calendar:write",
|
|
||||||
"contacts:read",
|
|
||||||
"contacts:write",
|
|
||||||
"files:read",
|
|
||||||
"files:write",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Generate authorization URL
|
|
||||||
auth_url = generate_oauth_url_for_flow2(
|
|
||||||
oidc_discovery_url=oidc_discovery_url,
|
|
||||||
server_client_id=server_client_id,
|
|
||||||
redirect_uri=redirect_uri,
|
|
||||||
state=state,
|
|
||||||
scopes=scopes,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
success=True,
|
success=True,
|
||||||
authorization_url=auth_url,
|
provisioning_url=astrolabe_url,
|
||||||
message=(
|
message=(
|
||||||
"Please visit the authorization URL to grant the MCP server "
|
"Visit Astrolabe settings to provision background sync access.\n\n"
|
||||||
"offline access to your Nextcloud resources. This is a one-time "
|
"You can choose either:\n"
|
||||||
"setup that allows the server to access Nextcloud on your behalf "
|
"- App password (works today, recommended for now)\n"
|
||||||
"even when you're not actively connected."
|
"- OAuth refresh token (future, when Nextcloud fully supports OAuth)\n\n"
|
||||||
|
"After provisioning, background sync will enable the MCP server to "
|
||||||
|
"access Nextcloud resources even when you're not actively connected."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ with ENABLE_OFFLINE_ACCESS=true:
|
|||||||
- User Manager: Monitors RefreshTokenStorage for user changes
|
- User Manager: Monitors RefreshTokenStorage for user changes
|
||||||
- Per-User Scanners: One scanner task per provisioned user
|
- Per-User Scanners: One scanner task per provisioned user
|
||||||
- Shared Processor Pool: Processes documents from all users
|
- Shared Processor Pool: Processes documents from all users
|
||||||
|
|
||||||
|
Supports dual credential types for background sync:
|
||||||
|
- App passwords (interim solution, works today)
|
||||||
|
- OAuth refresh tokens (future, when Nextcloud supports OAuth for app APIs)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -18,7 +22,9 @@ from anyio.streams.memory import (
|
|||||||
MemoryObjectReceiveStream,
|
MemoryObjectReceiveStream,
|
||||||
MemoryObjectSendStream,
|
MemoryObjectSendStream,
|
||||||
)
|
)
|
||||||
|
from httpx import BasicAuth
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
@@ -60,6 +66,10 @@ async def get_user_client(
|
|||||||
) -> NextcloudClient:
|
) -> NextcloudClient:
|
||||||
"""Get an authenticated NextcloudClient for a user.
|
"""Get an authenticated NextcloudClient for a user.
|
||||||
|
|
||||||
|
Supports dual credential types with priority:
|
||||||
|
1. App password from Astrolabe (works today with BasicAuth)
|
||||||
|
2. OAuth refresh token from storage (for future when OAuth fully supported)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user_id: User identifier
|
user_id: User identifier
|
||||||
token_broker: Token broker for obtaining access tokens
|
token_broker: Token broker for obtaining access tokens
|
||||||
@@ -71,6 +81,36 @@ async def get_user_client(
|
|||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned offline access
|
NotProvisionedError: If user has not provisioned offline access
|
||||||
"""
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
# Try app password first (interim solution, works today)
|
||||||
|
if settings.oidc_client_id and settings.oidc_client_secret:
|
||||||
|
try:
|
||||||
|
astrolabe = AstrolabeClient(
|
||||||
|
nextcloud_host=nextcloud_host,
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
)
|
||||||
|
app_password = await astrolabe.get_user_app_password(user_id)
|
||||||
|
|
||||||
|
if app_password:
|
||||||
|
logger.info(
|
||||||
|
f"Using app password for background sync: {user_id} "
|
||||||
|
f"(credential_type=app_password)"
|
||||||
|
)
|
||||||
|
return NextcloudClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
username=user_id,
|
||||||
|
auth=BasicAuth(user_id, app_password),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"App password not available for {user_id}: {e}")
|
||||||
|
|
||||||
|
# Fall back to OAuth refresh token
|
||||||
|
logger.info(
|
||||||
|
f"Using OAuth refresh token for background sync: {user_id} "
|
||||||
|
f"(credential_type=refresh_token)"
|
||||||
|
)
|
||||||
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
token = await token_broker.get_background_token(user_id, VECTOR_SYNC_SCOPES)
|
||||||
if not token:
|
if not token:
|
||||||
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
|
raise NotProvisionedError(f"User {user_id} has not provisioned offline access")
|
||||||
|
|||||||
+13
-4
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.52.1"
|
version = "0.56.2"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -89,14 +89,23 @@ version_scheme = "pep440"
|
|||||||
version_provider = "uv"
|
version_provider = "uv"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
major_version_zero = true
|
major_version_zero = true
|
||||||
|
|
||||||
|
# MCP server version files + Helm appVersion
|
||||||
version_files = [
|
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 = [
|
ignored_tag_formats = [
|
||||||
"nextcloud-mcp-server-*"
|
"nextcloud-mcp-server-*", # Helm chart tags
|
||||||
|
"astrolabe-v*", # Astrolabe tags
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# 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]
|
[tool.ruff.lint]
|
||||||
extend-select = ["I"]
|
extend-select = ["I"]
|
||||||
|
|
||||||
|
|||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Bump Astrolabe app version
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Parse optional --increment flag
|
||||||
|
INCREMENT=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--increment)
|
||||||
|
INCREMENT="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Error: Unknown option: $1" >&2
|
||||||
|
echo "Usage: $0 [--increment PATCH|MINOR|MAJOR]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validate dependencies
|
||||||
|
command -v uv >/dev/null 2>&1 || {
|
||||||
|
echo "❌ Error: uv not found" >&2
|
||||||
|
echo " Install from https://docs.astral.sh/uv/" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate Astrolabe directory exists
|
||||||
|
if [ ! -d "third_party/astrolabe" ]; then
|
||||||
|
echo "❌ Error: Must run from repository root (third_party/astrolabe not found)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd third_party/astrolabe
|
||||||
|
|
||||||
|
# Validate required files exist
|
||||||
|
if [ ! -f "appinfo/info.xml" ]; then
|
||||||
|
echo "❌ Error: appinfo/info.xml not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
echo "❌ Error: package.json not found" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Bumping Astrolabe version..."
|
||||||
|
if [ -n "$INCREMENT" ]; then
|
||||||
|
echo " Forcing $INCREMENT bump"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build commitizen command
|
||||||
|
CZ_CMD="uv run cz --config .cz.toml bump --yes"
|
||||||
|
if [ -n "$INCREMENT" ]; then
|
||||||
|
CZ_CMD="$CZ_CMD --increment $INCREMENT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run commitizen bump and capture output
|
||||||
|
if ! output=$($CZ_CMD 2>&1); then
|
||||||
|
cd ../..
|
||||||
|
|
||||||
|
# Check if this is the expected "no commits to bump" case
|
||||||
|
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
||||||
|
echo "ℹ️ No commits eligible for version bump" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Otherwise, this is an actual error
|
||||||
|
echo "❌ Error: Version bump failed" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Common causes:" >&2
|
||||||
|
echo " - No commits with scope 'astrolabe' since last version" >&2
|
||||||
|
echo " - No conventional commits found (use feat(astrolabe):, fix(astrolabe):, etc.)" >&2
|
||||||
|
echo " - Git working directory not clean" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$output"
|
||||||
|
echo ""
|
||||||
|
echo "✓ Astrolabe version bumped successfully"
|
||||||
|
echo " Updated: appinfo/info.xml, package.json"
|
||||||
|
echo " Tag format: astrolabe-v\${version}"
|
||||||
|
echo ""
|
||||||
|
echo "Next steps:"
|
||||||
|
echo " cd ../.."
|
||||||
|
echo " git push --follow-tags"
|
||||||
|
|
||||||
|
cd ../..
|
||||||
Executable
+86
@@ -0,0 +1,86 @@
|
|||||||
|
#!/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 ../..
|
||||||
|
|
||||||
|
# Check if this is the expected "no commits to bump" case
|
||||||
|
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
||||||
|
echo "ℹ️ No commits eligible for version bump" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Otherwise, this is an actual error
|
||||||
|
echo "❌ Error: Version bump failed" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Common causes:" >&2
|
||||||
|
echo " - No commits with scope '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 ../..
|
||||||
Executable
+72
@@ -0,0 +1,72 @@
|
|||||||
|
#!/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
|
||||||
|
# Check if this is the expected "no commits to bump" case
|
||||||
|
if echo "$output" | grep -q "\[NO_COMMITS_TO_BUMP\]"; then
|
||||||
|
echo "ℹ️ No commits eligible for version bump" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Otherwise, this is an actual error
|
||||||
|
echo "❌ Error: Version bump failed" >&2
|
||||||
|
echo "$output" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Common causes:" >&2
|
||||||
|
echo " - No commits 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"
|
||||||
Executable
+106
@@ -0,0 +1,106 @@
|
|||||||
|
#!/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
|
||||||
+228
-2
@@ -114,6 +114,7 @@ async def create_mcp_client_session(
|
|||||||
client_name: str = "MCP",
|
client_name: str = "MCP",
|
||||||
elicitation_callback: Any = None,
|
elicitation_callback: Any = None,
|
||||||
sampling_callback: Any = None,
|
sampling_callback: Any = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""
|
"""
|
||||||
Factory function to create an MCP client session with proper lifecycle management.
|
Factory function to create an MCP client session with proper lifecycle management.
|
||||||
@@ -135,6 +136,8 @@ async def create_mcp_client_session(
|
|||||||
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
Should match signature: async def callback(context: RequestContext, params: ElicitRequestParams) -> ElicitResult | ErrorData
|
||||||
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
|
sampling_callback: Optional callback for handling sampling (LLM generation) requests.
|
||||||
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
|
Should match signature: async def callback(context: RequestContext, params: CreateMessageRequestParams) -> CreateMessageResult | ErrorData
|
||||||
|
headers: Optional custom headers (e.g., for BasicAuth). If both headers and token are provided,
|
||||||
|
custom headers take precedence.
|
||||||
|
|
||||||
Yields:
|
Yields:
|
||||||
Initialized MCP ClientSession
|
Initialized MCP ClientSession
|
||||||
@@ -147,8 +150,9 @@ async def create_mcp_client_session(
|
|||||||
"""
|
"""
|
||||||
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
logger.info(f"Creating Streamable HTTP client for {client_name}")
|
||||||
|
|
||||||
# Prepare headers with OAuth token if provided
|
# Prepare headers - custom headers take precedence over token-based auth
|
||||||
headers = {"Authorization": f"Bearer {token}"} if token else None
|
if headers is None:
|
||||||
|
headers = {"Authorization": f"Bearer {token}"} if token else None
|
||||||
|
|
||||||
# Use native async with - Python ensures LIFO cleanup
|
# Use native async with - Python ensures LIFO cleanup
|
||||||
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
|
# Cleanup order will be: ClientSession.__aexit__ -> streamablehttp_client.__aexit__
|
||||||
@@ -240,6 +244,32 @@ async def nc_mcp_oauth_client(
|
|||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def nc_mcp_basic_auth_client(
|
||||||
|
anyio_backend,
|
||||||
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
|
"""
|
||||||
|
Fixture to create an MCP client session with BasicAuth credentials.
|
||||||
|
Connects to the multi-user BasicAuth MCP server on port 8003 with ENABLE_MULTI_USER_BASIC_AUTH=true.
|
||||||
|
|
||||||
|
Uses BasicAuth credentials for multi-user pass-through mode (ADR-020).
|
||||||
|
Credentials are passed in Authorization header and forwarded to Nextcloud APIs.
|
||||||
|
|
||||||
|
Uses anyio pytest plugin for proper async fixture handling.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
credentials = base64.b64encode(b"admin:admin").decode("utf-8")
|
||||||
|
auth_header = f"Basic {credentials}"
|
||||||
|
|
||||||
|
async for session in create_mcp_client_session(
|
||||||
|
url="http://localhost:8003/mcp",
|
||||||
|
headers={"Authorization": auth_header},
|
||||||
|
client_name="BasicAuth MCP (Multi-User)",
|
||||||
|
):
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="session")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_mcp_oauth_jwt_client(
|
async def nc_mcp_oauth_jwt_client(
|
||||||
anyio_backend,
|
anyio_backend,
|
||||||
@@ -3187,3 +3217,199 @@ async def nc_mcp_keycloak_client_no_custom_scopes(
|
|||||||
client_name="Keycloak No Custom Scopes MCP",
|
client_name="Keycloak No Custom Scopes MCP",
|
||||||
):
|
):
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Astrolabe Dynamic Configuration Fixtures
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
async def configure_astrolabe_for_mcp_server(nc_client):
|
||||||
|
"""Configure Astrolabe app to connect to a specific MCP server.
|
||||||
|
|
||||||
|
This fixture dynamically configures the Astrolabe app's MCP server settings
|
||||||
|
and OAuth client, allowing tests to verify integration with different MCP
|
||||||
|
server deployments (mcp-oauth, mcp-keycloak, mcp-multi-user-basic, etc.).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
async def test_my_integration(configure_astrolabe_for_mcp_server):
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||||
|
mcp_server_public_url="http://localhost:8001"
|
||||||
|
)
|
||||||
|
# ... test Astrolabe integration ...
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nc_client: NextcloudClient fixture for occ command execution
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Async function that accepts:
|
||||||
|
- mcp_server_internal_url: Internal Docker URL for PHP app to call MCP APIs
|
||||||
|
- mcp_server_public_url: Public URL for OAuth token audience validation
|
||||||
|
- client_id: Optional OAuth client ID (default: "nextcloudMcpServerUIPublicClient")
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
async def _configure(
|
||||||
|
mcp_server_internal_url: str,
|
||||||
|
mcp_server_public_url: str,
|
||||||
|
client_id: str = "nextcloudMcpServerUIPublicClient",
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Configure Astrolabe for the specified MCP server.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with client_id and client_secret
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
f"Configuring Astrolabe for MCP server: {mcp_server_internal_url} (public: {mcp_server_public_url})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure MCP server URLs in Nextcloud system config
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"config:system:set",
|
||||||
|
"mcp_server_url",
|
||||||
|
"--value",
|
||||||
|
mcp_server_internal_url,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"config:system:set",
|
||||||
|
"mcp_server_public_url",
|
||||||
|
"--value",
|
||||||
|
mcp_server_public_url,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✓ MCP server URLs configured")
|
||||||
|
|
||||||
|
# Remove existing OAuth client if it exists
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"oidc:remove",
|
||||||
|
client_id,
|
||||||
|
],
|
||||||
|
check=False, # Don't fail if client doesn't exist
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
logger.info(f"Removed existing OAuth client: {client_id}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create OAuth client for Astrolabe
|
||||||
|
redirect_uri = "http://localhost:8080/apps/astrolabe/oauth/callback"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"oidc:create",
|
||||||
|
"Astrolabe",
|
||||||
|
redirect_uri,
|
||||||
|
"--client_id",
|
||||||
|
client_id,
|
||||||
|
"--type",
|
||||||
|
"confidential",
|
||||||
|
"--flow",
|
||||||
|
"code",
|
||||||
|
"--token_type",
|
||||||
|
"jwt",
|
||||||
|
"--resource_url",
|
||||||
|
mcp_server_public_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",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse client_secret from JSON output
|
||||||
|
client_output = json.loads(result.stdout.strip())
|
||||||
|
client_secret = client_output.get("client_secret")
|
||||||
|
|
||||||
|
if not client_secret:
|
||||||
|
raise ValueError(
|
||||||
|
"Failed to extract client_secret from OAuth client creation"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✓ OAuth client created: {client_id}")
|
||||||
|
|
||||||
|
# Store client credentials in Nextcloud system config
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"config:system:set",
|
||||||
|
"astrolabe_client_id",
|
||||||
|
"--value",
|
||||||
|
client_id,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"app",
|
||||||
|
"php",
|
||||||
|
"/var/www/html/occ",
|
||||||
|
"config:system:set",
|
||||||
|
"astrolabe_client_secret",
|
||||||
|
"--value",
|
||||||
|
client_secret,
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("✓ Client credentials stored in system config")
|
||||||
|
logger.info(f"Astrolabe configured for MCP server: {mcp_server_public_url}")
|
||||||
|
|
||||||
|
return {"client_id": client_id, "client_secret": client_secret}
|
||||||
|
|
||||||
|
return _configure
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"""Integration tests for app password provisioning via Astrolabe.
|
||||||
|
|
||||||
|
Tests the complete flow:
|
||||||
|
1. User stores app password via Astrolabe API
|
||||||
|
2. MCP server retrieves it via OAuth client credentials
|
||||||
|
3. Background sync uses it to access Nextcloud
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from httpx import BasicAuth
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.oauth_sync import get_user_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_astrolabe_client_initialization():
|
||||||
|
"""Test AstrolabeClient can be instantiated."""
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
client_id="test-client",
|
||||||
|
client_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert client is not None
|
||||||
|
assert client.nextcloud_host == "http://localhost:8080"
|
||||||
|
assert client.client_id == "test-client"
|
||||||
|
assert client.client_secret == "test-secret"
|
||||||
|
assert client._token_cache is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_astrolabe_client_get_access_token_requires_oidc():
|
||||||
|
"""Test that getting access token requires OIDC discovery endpoint."""
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
client_id="test-client",
|
||||||
|
client_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
# This will fail without proper OIDC setup, which is expected
|
||||||
|
# The test verifies the client follows the OAuth client credentials flow
|
||||||
|
try:
|
||||||
|
token = await client.get_access_token()
|
||||||
|
# If we get here, OIDC is configured
|
||||||
|
assert token is not None
|
||||||
|
except Exception as e:
|
||||||
|
# Expected if OIDC not fully configured for test client
|
||||||
|
# 400/401/403/404 all indicate the flow is working but credentials are invalid
|
||||||
|
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_get_user_app_password_returns_none_for_unconfigured_user():
|
||||||
|
"""Test that get_user_app_password returns None for users without app passwords."""
|
||||||
|
# This requires valid OAuth client credentials
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
if not settings.oidc_client_id or not settings.oidc_client_secret:
|
||||||
|
pytest.skip("OAuth client credentials not configured")
|
||||||
|
|
||||||
|
client = AstrolabeClient(
|
||||||
|
nextcloud_host=settings.nextcloud_host or "http://localhost:8080",
|
||||||
|
client_id=settings.oidc_client_id,
|
||||||
|
client_secret=settings.oidc_client_secret,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Try to get app password for a user that hasn't provisioned one
|
||||||
|
try:
|
||||||
|
app_password = await client.get_user_app_password("nonexistent_user")
|
||||||
|
# Should return None for unconfigured user (404 response)
|
||||||
|
assert app_password is None
|
||||||
|
except Exception as e:
|
||||||
|
# May fail with auth error if OAuth not fully configured
|
||||||
|
assert any(code in str(e) for code in ["400", "401", "403", "404"])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_dual_credential_support_in_background_sync(mocker):
|
||||||
|
"""Test that background sync tries app password first, then refresh token."""
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
|
# Mock AstrolabeClient to return an app password
|
||||||
|
mock_astrolabe = mocker.AsyncMock()
|
||||||
|
mock_astrolabe.get_user_app_password.return_value = "test-app-password-12345"
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||||
|
return_value=mock_astrolabe,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock TokenBrokerService (shouldn't be called if app password works)
|
||||||
|
mock_token_broker = mocker.MagicMock(spec=TokenBrokerService)
|
||||||
|
|
||||||
|
# Call get_user_client - should use app password
|
||||||
|
try:
|
||||||
|
_client = await get_user_client(
|
||||||
|
user_id="test_user",
|
||||||
|
token_broker=mock_token_broker,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify app password was requested
|
||||||
|
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||||
|
|
||||||
|
# Verify token broker was NOT called (app password took priority)
|
||||||
|
mock_token_broker.get_background_token.assert_not_called()
|
||||||
|
|
||||||
|
# Verify client uses BasicAuth
|
||||||
|
assert _client.auth is not None
|
||||||
|
assert isinstance(_client.auth, BasicAuth)
|
||||||
|
except Exception:
|
||||||
|
# May fail in test environment, but we verified the priority logic
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_background_sync_falls_back_to_refresh_token(mocker):
|
||||||
|
"""Test that background sync falls back to refresh token if no app password."""
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
|
# Mock AstrolabeClient to return None (no app password)
|
||||||
|
mock_astrolabe = mocker.AsyncMock()
|
||||||
|
mock_astrolabe.get_user_app_password.return_value = None
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
"nextcloud_mcp_server.vector.oauth_sync.AstrolabeClient",
|
||||||
|
return_value=mock_astrolabe,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mock TokenBrokerService to return an access token
|
||||||
|
mock_token_broker = mocker.AsyncMock(spec=TokenBrokerService)
|
||||||
|
mock_token_broker.get_background_token.return_value = "test-access-token"
|
||||||
|
|
||||||
|
# Call get_user_client - should fall back to refresh token
|
||||||
|
try:
|
||||||
|
_client = await get_user_client(
|
||||||
|
user_id="test_user",
|
||||||
|
token_broker=mock_token_broker,
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify app password was attempted first
|
||||||
|
mock_astrolabe.get_user_app_password.assert_called_once_with("test_user")
|
||||||
|
|
||||||
|
# Verify token broker was called as fallback
|
||||||
|
mock_token_broker.get_background_token.assert_called_once()
|
||||||
|
except Exception:
|
||||||
|
# May fail in test environment, but we verified the fallback logic
|
||||||
|
pass
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Integration tests for multi-user BasicAuth pass-through mode.
|
||||||
|
|
||||||
|
Tests that BasicAuth credentials are extracted from request headers
|
||||||
|
and passed through to Nextcloud APIs without storage (stateless).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_basic_auth_pass_through_notes_list(nc_mcp_basic_auth_client):
|
||||||
|
"""Test BasicAuth pass-through with notes list tool."""
|
||||||
|
# Call tool - BasicAuth header is set at connection level by fixture
|
||||||
|
response = await nc_mcp_basic_auth_client.call_tool("nc_notes_list", {})
|
||||||
|
|
||||||
|
# Verify tool executed successfully with pass-through auth
|
||||||
|
assert response is not None
|
||||||
|
assert "results" in response or "content" in response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_basic_auth_pass_through_notes_create(nc_mcp_basic_auth_client):
|
||||||
|
"""Test BasicAuth pass-through with notes create tool."""
|
||||||
|
# Create a note using BasicAuth
|
||||||
|
response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
|
"nc_notes_create",
|
||||||
|
{
|
||||||
|
"title": "BasicAuth Test Note",
|
||||||
|
"content": "This note was created via BasicAuth pass-through",
|
||||||
|
"category": "Test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert response.get("success") is True or "note_id" in response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
async def test_basic_auth_pass_through_search(nc_mcp_basic_auth_client):
|
||||||
|
"""Test BasicAuth pass-through with search tool."""
|
||||||
|
# Search notes using BasicAuth
|
||||||
|
response = await nc_mcp_basic_auth_client.call_tool(
|
||||||
|
"nc_notes_search", {"query": "BasicAuth"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response is not None
|
||||||
|
assert "results" in response or "content" in response
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""Test Astrolabe integration with multiple MCP server deployments.
|
||||||
|
|
||||||
|
This test suite verifies that the Astrolabe app can be dynamically configured
|
||||||
|
to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.).
|
||||||
|
|
||||||
|
The configuration is managed dynamically during tests using the
|
||||||
|
configure_astrolabe_for_mcp_server fixture, which allows testing multiple
|
||||||
|
deployment scenarios without requiring static post-installation configuration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
|
class TestAstrolabeMultiServerIntegration:
|
||||||
|
"""Test suite for Astrolabe integration with multiple MCP servers."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"mcp_server_config",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "mcp-oauth",
|
||||||
|
"internal_url": "http://mcp-oauth:8001",
|
||||||
|
"public_url": "http://localhost:8001",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "mcp-keycloak",
|
||||||
|
"internal_url": "http://mcp-keycloak:8002",
|
||||||
|
"public_url": "http://localhost:8002",
|
||||||
|
},
|
||||||
|
# Add more MCP server configurations as needed:
|
||||||
|
# {
|
||||||
|
# "name": "mcp-multi-user-basic",
|
||||||
|
# "internal_url": "http://mcp-multi-user-basic:8000",
|
||||||
|
# "public_url": "http://localhost:8003",
|
||||||
|
# },
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_astrolabe_configuration_for_different_servers(
|
||||||
|
self, configure_astrolabe_for_mcp_server, mcp_server_config
|
||||||
|
):
|
||||||
|
"""Test that Astrolabe can be configured for different MCP servers.
|
||||||
|
|
||||||
|
This test verifies that:
|
||||||
|
1. The configure_astrolabe_for_mcp_server fixture successfully configures
|
||||||
|
the Astrolabe app for different MCP server endpoints
|
||||||
|
2. OAuth client credentials are properly generated and stored
|
||||||
|
3. The configuration can be dynamically changed between tests
|
||||||
|
"""
|
||||||
|
logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...")
|
||||||
|
|
||||||
|
# Configure Astrolabe for the specific MCP server
|
||||||
|
credentials = await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url=mcp_server_config["internal_url"],
|
||||||
|
mcp_server_public_url=mcp_server_config["public_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify credentials were returned
|
||||||
|
assert "client_id" in credentials
|
||||||
|
assert "client_secret" in credentials
|
||||||
|
assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||||
|
assert len(credentials["client_secret"]) > 0
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✓ Astrolabe successfully configured for {mcp_server_config['name']}"
|
||||||
|
)
|
||||||
|
logger.info(f" Internal URL: {mcp_server_config['internal_url']}")
|
||||||
|
logger.info(f" Public URL: {mcp_server_config['public_url']}")
|
||||||
|
logger.info(f" Client ID: {credentials['client_id']}")
|
||||||
|
logger.info(f" Client Secret: {credentials['client_secret'][:8]}...")
|
||||||
|
|
||||||
|
async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server):
|
||||||
|
"""Test that Astrolabe can be reconfigured multiple times in the same session.
|
||||||
|
|
||||||
|
This verifies that the OAuth client can be recreated with different
|
||||||
|
settings without conflicts.
|
||||||
|
"""
|
||||||
|
# First configuration: mcp-oauth
|
||||||
|
logger.info("First configuration: mcp-oauth")
|
||||||
|
credentials1 = await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||||
|
mcp_server_public_url="http://localhost:8001",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||||
|
|
||||||
|
# Second configuration: mcp-keycloak (reconfiguration)
|
||||||
|
logger.info("Second configuration: mcp-keycloak (reconfiguration)")
|
||||||
|
credentials2 = await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-keycloak:8002",
|
||||||
|
mcp_server_public_url="http://localhost:8002",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient"
|
||||||
|
|
||||||
|
# Client secrets should be different (new client created)
|
||||||
|
assert credentials1["client_secret"] != credentials2["client_secret"]
|
||||||
|
|
||||||
|
logger.info("✓ Astrolabe successfully reconfigured without conflicts")
|
||||||
@@ -10,8 +10,14 @@ logger = logging.getLogger(__name__)
|
|||||||
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
pytestmark = [pytest.mark.integration, pytest.mark.oauth]
|
||||||
|
|
||||||
|
|
||||||
async def test_capture_settings_page(browser):
|
async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server):
|
||||||
"""Capture what's actually rendered on the personal settings page."""
|
"""Capture what's actually rendered on the personal settings page."""
|
||||||
|
# Configure Astrolabe for mcp-oauth server
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||||
|
mcp_server_public_url="http://localhost:8001",
|
||||||
|
)
|
||||||
|
|
||||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
||||||
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
username = os.getenv("NEXTCLOUD_USERNAME", "admin")
|
||||||
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
|
||||||
|
|||||||
@@ -44,14 +44,32 @@ async def nc_admin_http_client(nextcloud_credentials):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def authorized_nc_session(browser, nextcloud_credentials):
|
async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server):
|
||||||
|
"""Configure Astrolabe to connect to mcp-oauth server before running tests.
|
||||||
|
|
||||||
|
This module-scoped fixture ensures Astrolabe is properly configured
|
||||||
|
for the mcp-oauth server (http://localhost:8001) before any tests run.
|
||||||
|
"""
|
||||||
|
logger.info("Configuring Astrolabe for mcp-oauth server...")
|
||||||
|
await configure_astrolabe_for_mcp_server(
|
||||||
|
mcp_server_internal_url="http://mcp-oauth:8001",
|
||||||
|
mcp_server_public_url="http://localhost:8001",
|
||||||
|
)
|
||||||
|
logger.info("✓ Astrolabe configured for mcp-oauth server")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
async def authorized_nc_session(
|
||||||
|
browser, nextcloud_credentials, configure_astrolabe_for_tests
|
||||||
|
):
|
||||||
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
|
"""Module-scoped fixture that logs in and authorizes the NC PHP app once.
|
||||||
|
|
||||||
This fixture:
|
This fixture:
|
||||||
1. Creates a browser context
|
1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests)
|
||||||
2. Logs in to Nextcloud
|
2. Creates a browser context
|
||||||
3. Authorizes the MCP Server UI app (if not already authorized)
|
3. Logs in to Nextcloud
|
||||||
4. Returns the page for use in all tests
|
4. Authorizes the MCP Server UI app (if not already authorized)
|
||||||
|
5. Returns the page for use in all tests
|
||||||
|
|
||||||
The authorization is done once and reused for all tests in this module.
|
The authorization is done once and reused for all tests in this module.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Unit tests for BasicAuthMiddleware."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.app import BasicAuthMiddleware
|
||||||
|
|
||||||
|
|
||||||
|
class MockApp:
|
||||||
|
"""Mock ASGI app for testing middleware."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.called = False
|
||||||
|
self.received_scope = None
|
||||||
|
|
||||||
|
async def __call__(self, scope, receive, send):
|
||||||
|
self.called = True
|
||||||
|
self.received_scope = scope
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_valid_credentials():
|
||||||
|
"""Test that middleware correctly extracts valid BasicAuth credentials."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
credentials = base64.b64encode(b"admin:password123").decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
assert "state" in scope
|
||||||
|
assert "basic_auth" in scope["state"]
|
||||||
|
assert scope["state"]["basic_auth"]["username"] == "admin"
|
||||||
|
assert scope["state"]["basic_auth"]["password"] == "password123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_password_with_colon():
|
||||||
|
"""Test that middleware handles passwords containing colons."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
# Password contains colon - should split on first colon only
|
||||||
|
credentials = base64.b64encode(b"user:pass:word:123").decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert scope["state"]["basic_auth"]["username"] == "user"
|
||||||
|
assert scope["state"]["basic_auth"]["password"] == "pass:word:123"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_invalid_base64():
|
||||||
|
"""Test that middleware handles invalid base64 encoding gracefully."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", b"Basic INVALID_BASE64!!!")],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
# Should not have basic_auth in state due to error
|
||||||
|
assert "basic_auth" not in scope.get("state", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_missing_authorization_header():
|
||||||
|
"""Test that middleware handles missing Authorization header."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
# Should not have basic_auth in state
|
||||||
|
assert "basic_auth" not in scope.get("state", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_wrong_auth_scheme():
|
||||||
|
"""Test that middleware ignores non-Basic auth schemes."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", b"Bearer some_token")],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
# Should not have basic_auth in state
|
||||||
|
assert "basic_auth" not in scope.get("state", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_malformed_credentials():
|
||||||
|
"""Test that middleware handles credentials without colon separator."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
# Credentials without colon separator
|
||||||
|
credentials = base64.b64encode(b"username_no_password").decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
# Should not have basic_auth in state due to error
|
||||||
|
assert "basic_auth" not in scope.get("state", {})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_non_http_scope():
|
||||||
|
"""Test that middleware passes through non-HTTP scopes unchanged."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "websocket",
|
||||||
|
"headers": [(b"authorization", b"Basic dXNlcjpwYXNz")],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
# Should not process websocket scopes
|
||||||
|
assert "state" not in scope
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_preserves_existing_state():
|
||||||
|
"""Test that middleware preserves existing state data."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
credentials = base64.b64encode(b"user:pass").decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
"state": {"existing_key": "existing_value"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
assert scope["state"]["existing_key"] == "existing_value"
|
||||||
|
assert scope["state"]["basic_auth"]["username"] == "user"
|
||||||
|
assert scope["state"]["basic_auth"]["password"] == "pass"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_empty_password():
|
||||||
|
"""Test that middleware handles empty passwords."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
credentials = base64.b64encode(b"user:").decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
assert scope["state"]["basic_auth"]["username"] == "user"
|
||||||
|
assert scope["state"]["basic_auth"]["password"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_basic_auth_middleware_unicode_credentials():
|
||||||
|
"""Test that middleware handles Unicode characters in credentials."""
|
||||||
|
# Arrange
|
||||||
|
mock_app = MockApp()
|
||||||
|
middleware = BasicAuthMiddleware(mock_app)
|
||||||
|
|
||||||
|
# Username and password with Unicode characters
|
||||||
|
credentials = base64.b64encode("üser:pässwörd".encode("utf-8")).decode("utf-8")
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"headers": [(b"authorization", f"Basic {credentials}".encode())],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Act
|
||||||
|
await middleware(scope, None, None) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# Assert
|
||||||
|
assert mock_app.called
|
||||||
|
assert scope["state"]["basic_auth"]["username"] == "üser"
|
||||||
|
assert scope["state"]["basic_auth"]["password"] == "pässwörd"
|
||||||
@@ -0,0 +1,578 @@
|
|||||||
|
"""Unit tests for configuration validation and mode detection.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Mode detection logic
|
||||||
|
- Configuration validation for each mode
|
||||||
|
- Error message generation
|
||||||
|
- Edge cases and boundary conditions
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import Settings
|
||||||
|
from nextcloud_mcp_server.config_validators import (
|
||||||
|
AuthMode,
|
||||||
|
detect_auth_mode,
|
||||||
|
get_mode_summary,
|
||||||
|
validate_configuration,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModeDetection:
|
||||||
|
"""Test auth mode detection from configuration."""
|
||||||
|
|
||||||
|
def test_smithery_mode_detection(self):
|
||||||
|
"""Test Smithery mode is detected from environment variable."""
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
|
||||||
|
def test_token_exchange_mode_detection(self):
|
||||||
|
"""Test token exchange mode is detected."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|
||||||
|
def test_multi_user_basic_mode_detection(self):
|
||||||
|
"""Test multi-user BasicAuth mode is detected."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
|
||||||
|
def test_single_user_basic_mode_detection(self):
|
||||||
|
"""Test single-user BasicAuth mode is detected."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
|
||||||
|
def test_oauth_single_audience_default(self):
|
||||||
|
"""Test OAuth single-audience is default mode."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
|
||||||
|
def test_mode_priority_smithery_over_all(self):
|
||||||
|
"""Test Smithery mode has highest priority."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
|
||||||
|
def test_mode_priority_token_exchange_over_basic(self):
|
||||||
|
"""Test token exchange has priority over BasicAuth."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = detect_auth_mode(settings)
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleUserBasicValidation:
|
||||||
|
"""Test validation for single-user BasicAuth mode."""
|
||||||
|
|
||||||
|
def test_valid_minimal_config(self):
|
||||||
|
"""Test valid minimal single-user BasicAuth config."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_valid_with_vector_sync(self):
|
||||||
|
"""Test valid config with vector sync enabled."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
vector_sync_enabled=True,
|
||||||
|
qdrant_location=":memory:",
|
||||||
|
ollama_base_url="http://ollama:11434",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_missing_required_host(self):
|
||||||
|
"""Test error when NEXTCLOUD_HOST is missing."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_missing_required_username(self):
|
||||||
|
"""Test that partial credentials fall back to OAuth mode."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_password="password", # Password without username
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Mode detection requires BOTH username AND password for single-user BasicAuth
|
||||||
|
# If only one is present, it defaults to OAuth single-audience
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
# In OAuth mode, having a password set is forbidden
|
||||||
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_missing_required_password(self):
|
||||||
|
"""Test that partial credentials fall back to OAuth mode."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin", # Username without password
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Mode detection requires BOTH username AND password for single-user BasicAuth
|
||||||
|
# If only one is present, it defaults to OAuth single-audience
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
# In OAuth mode, having a username set is forbidden
|
||||||
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_forbidden_multi_user_basic_auth(self):
|
||||||
|
"""Test error when ENABLE_MULTI_USER_BASIC_AUTH is set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: This will detect as MULTI_USER_BASIC due to priority
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
# It will fail multi-user validation because username/password are forbidden
|
||||||
|
assert len(errors) > 0
|
||||||
|
|
||||||
|
def test_forbidden_token_exchange(self):
|
||||||
|
"""Test error when ENABLE_TOKEN_EXCHANGE is set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: This will detect as OAUTH_TOKEN_EXCHANGE due to priority
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
# It will fail OAuth validation
|
||||||
|
|
||||||
|
def test_vector_sync_without_embedding_provider_uses_fallback(self):
|
||||||
|
"""Test that vector sync works with Simple provider fallback (no config needed)."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
vector_sync_enabled=True,
|
||||||
|
qdrant_location=":memory:",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
# Should pass - Simple provider is always available as fallback
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMultiUserBasicValidation:
|
||||||
|
"""Test validation for multi-user BasicAuth mode."""
|
||||||
|
|
||||||
|
def test_valid_minimal_config(self):
|
||||||
|
"""Test valid minimal multi-user BasicAuth config."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_valid_with_offline_access(self):
|
||||||
|
"""Test valid config with offline access enabled."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_client_id="test-client",
|
||||||
|
oidc_client_secret="test-secret",
|
||||||
|
token_encryption_key="test-key-" + "a" * 32,
|
||||||
|
token_storage_db="/tmp/tokens.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_missing_required_host(self):
|
||||||
|
"""Test error when NEXTCLOUD_HOST is missing."""
|
||||||
|
settings = Settings(
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_forbidden_username_password(self):
|
||||||
|
"""Test error when NEXTCLOUD_USERNAME/PASSWORD are set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Multi-user BasicAuth has higher priority than single-user in detection
|
||||||
|
# (explicit flags come before credentials)
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
# Should report errors for forbidden username/password
|
||||||
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
||||||
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_offline_access_missing_oauth_credentials(self):
|
||||||
|
"""Test error when offline access enabled but OAuth credentials missing."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
token_encryption_key="test-key-" + "a" * 32,
|
||||||
|
token_storage_db="/tmp/tokens.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert any("oidc_client_id" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_offline_access_missing_encryption_key(self):
|
||||||
|
"""Test error when offline access enabled but encryption key missing."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
enable_offline_access=True,
|
||||||
|
oidc_client_id="test-client",
|
||||||
|
oidc_client_secret="test-secret",
|
||||||
|
token_storage_db="/tmp/tokens.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_vector_sync_requires_offline_access(self):
|
||||||
|
"""Test error when vector sync enabled but offline access disabled."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_multi_user_basic_auth=True,
|
||||||
|
vector_sync_enabled=True,
|
||||||
|
qdrant_location=":memory:",
|
||||||
|
ollama_base_url="http://ollama:11434",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.MULTI_USER_BASIC
|
||||||
|
assert any("enable_offline_access" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthSingleAudienceValidation:
|
||||||
|
"""Test validation for OAuth single-audience mode."""
|
||||||
|
|
||||||
|
def test_valid_minimal_config(self):
|
||||||
|
"""Test valid minimal OAuth single-audience config."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_valid_with_static_credentials(self):
|
||||||
|
"""Test valid config with static OAuth credentials."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
oidc_client_id="test-client",
|
||||||
|
oidc_client_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_valid_with_offline_access(self):
|
||||||
|
"""Test valid config with offline access."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
oidc_client_id="test-client",
|
||||||
|
oidc_client_secret="test-secret",
|
||||||
|
enable_offline_access=True,
|
||||||
|
token_encryption_key="test-key-" + "a" * 32,
|
||||||
|
token_storage_db="/tmp/tokens.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_forbidden_username_password(self):
|
||||||
|
"""Test that username/password trigger single-user mode instead."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# This should detect as SINGLE_USER_BASIC
|
||||||
|
assert mode == AuthMode.SINGLE_USER_BASIC
|
||||||
|
|
||||||
|
def test_offline_access_missing_encryption_key(self):
|
||||||
|
"""Test error when offline access enabled but encryption key missing."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_offline_access=True,
|
||||||
|
token_storage_db="/tmp/tokens.db",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
assert any("token_encryption_key" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_vector_sync_requires_offline_access(self):
|
||||||
|
"""Test error when vector sync enabled but offline access disabled."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
vector_sync_enabled=True,
|
||||||
|
qdrant_location=":memory:",
|
||||||
|
ollama_base_url="http://ollama:11434",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE
|
||||||
|
assert any("enable_offline_access" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthTokenExchangeValidation:
|
||||||
|
"""Test validation for OAuth token exchange mode."""
|
||||||
|
|
||||||
|
def test_valid_minimal_config(self):
|
||||||
|
"""Test valid minimal OAuth token exchange config."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_valid_with_credentials(self):
|
||||||
|
"""Test valid config with OAuth credentials."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
oidc_client_id="test-client",
|
||||||
|
oidc_client_secret="test-secret",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_forbidden_username_password(self):
|
||||||
|
"""Test error when username/password are set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
enable_token_exchange=True,
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE
|
||||||
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
||||||
|
assert any("nextcloud_password" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmitheryValidation:
|
||||||
|
"""Test validation for Smithery stateless mode."""
|
||||||
|
|
||||||
|
def test_valid_empty_config(self):
|
||||||
|
"""Test valid empty config for Smithery mode."""
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
assert len(errors) == 0
|
||||||
|
|
||||||
|
def test_forbidden_nextcloud_host(self):
|
||||||
|
"""Test error when NEXTCLOUD_HOST is set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="http://localhost",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_forbidden_credentials(self):
|
||||||
|
"""Test error when credentials are set."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
assert any("nextcloud_username" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_forbidden_vector_sync(self):
|
||||||
|
"""Test error when vector sync is enabled."""
|
||||||
|
settings = Settings(
|
||||||
|
vector_sync_enabled=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"SMITHERY_DEPLOYMENT": "true"}):
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
assert mode == AuthMode.SMITHERY_STATELESS
|
||||||
|
assert any("vector_sync_enabled" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
|
||||||
|
class TestModeSummary:
|
||||||
|
"""Test mode summary generation."""
|
||||||
|
|
||||||
|
def test_single_user_basic_summary(self):
|
||||||
|
"""Test summary for single-user BasicAuth mode."""
|
||||||
|
summary = get_mode_summary(AuthMode.SINGLE_USER_BASIC)
|
||||||
|
|
||||||
|
assert "single_user_basic" in summary
|
||||||
|
assert "NEXTCLOUD_HOST" in summary
|
||||||
|
assert "NEXTCLOUD_USERNAME" in summary
|
||||||
|
assert "NEXTCLOUD_PASSWORD" in summary
|
||||||
|
assert "VECTOR_SYNC_ENABLED" in summary
|
||||||
|
|
||||||
|
def test_smithery_summary(self):
|
||||||
|
"""Test summary for Smithery mode."""
|
||||||
|
summary = get_mode_summary(AuthMode.SMITHERY_STATELESS)
|
||||||
|
|
||||||
|
assert "smithery" in summary
|
||||||
|
assert "session" in summary.lower()
|
||||||
|
assert "(none" in summary # No required config
|
||||||
|
|
||||||
|
def test_oauth_token_exchange_summary(self):
|
||||||
|
"""Test summary for OAuth token exchange mode."""
|
||||||
|
summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE)
|
||||||
|
|
||||||
|
assert "oauth_exchange" in summary
|
||||||
|
assert "ENABLE_TOKEN_EXCHANGE" in summary
|
||||||
|
assert "RFC 8693" in summary
|
||||||
|
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases and boundary conditions."""
|
||||||
|
|
||||||
|
def test_empty_string_treated_as_missing(self):
|
||||||
|
"""Test that empty strings are treated as missing values."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host="", # Empty string
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Should fail because nextcloud_host is effectively missing
|
||||||
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_whitespace_treated_as_missing(self):
|
||||||
|
"""Test that whitespace-only strings are treated as missing."""
|
||||||
|
settings = Settings(
|
||||||
|
nextcloud_host=" ", # Whitespace only
|
||||||
|
nextcloud_username="admin",
|
||||||
|
nextcloud_password="password",
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Should fail because nextcloud_host is effectively missing
|
||||||
|
assert any("nextcloud_host" in err.lower() for err in errors)
|
||||||
|
|
||||||
|
def test_multiple_errors_reported(self):
|
||||||
|
"""Test that multiple errors are all reported."""
|
||||||
|
settings = Settings(
|
||||||
|
# Missing all required fields for single-user BasicAuth
|
||||||
|
)
|
||||||
|
|
||||||
|
mode, errors = validate_configuration(settings)
|
||||||
|
|
||||||
|
# Should have errors for missing host (OAuth mode is default)
|
||||||
|
assert len(errors) > 0
|
||||||
Vendored
+25
@@ -0,0 +1,25 @@
|
|||||||
|
[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}}"
|
||||||
+1
-1
@@ -30,7 +30,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Get version matrix
|
- name: Get version matrix
|
||||||
id: versions
|
id: versions
|
||||||
uses: icewind1991/nextcloud-version-matrix@58becf3b4bb6dc6cef677b15e2fd8e7d48c0908f # v1.0.0
|
uses: icewind1991/nextcloud-version-matrix@c2bf575a3516752db5ce2915499d3f694885e2c7 # v1.0.0
|
||||||
|
|
||||||
php-lint:
|
php-lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
if: steps.checkout.outcome == 'success'
|
if: steps.checkout.outcome == 'success'
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||||
commit-message: 'fix(deps): Fix npm audit'
|
commit-message: 'fix(deps): Fix npm audit'
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7.0.11
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.COMMAND_BOT_PAT }}
|
token: ${{ secrets.COMMAND_BOT_PAT }}
|
||||||
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
|
commit-message: 'chore(dev-deps): Bump nextcloud/ocp package'
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
/tests/.phpunit.cache
|
/tests/.phpunit.cache
|
||||||
|
|
||||||
dist/
|
dist/
|
||||||
|
build/
|
||||||
node_modules/
|
node_modules/
|
||||||
js/
|
js/
|
||||||
css/
|
css/
|
||||||
|
|||||||
Vendored
+410
-5
@@ -1,12 +1,417 @@
|
|||||||
# Changelog
|
# Changelog - Astrolabe
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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.1.0/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- First release
|
- 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
|
||||||
|
|||||||
Vendored
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# 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 "========================================="
|
||||||
+5
-3
@@ -29,14 +29,16 @@ Astrolabe connects to a semantic search service that understands the meaning of
|
|||||||
|
|
||||||
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
See [documentation](https://github.com/cbcoutinho/nextcloud-mcp-server) for configuration details.
|
||||||
]]></description>
|
]]></description>
|
||||||
<version>0.1.0</version>
|
<version>0.4.4</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author mail="chris@coutinho.io" homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
<author homepage="https://github.com/cbcoutinho">Chris Coutinho</author>
|
||||||
<namespace>Astrolabe</namespace>
|
<namespace>Astrolabe</namespace>
|
||||||
<category>ai</category>
|
<category>ai</category>
|
||||||
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
|
<bugs>https://github.com/cbcoutinho/nextcloud-mcp-server/issues</bugs>
|
||||||
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
|
<repository type="git">https://github.com/cbcoutinho/nextcloud-mcp-server</repository>
|
||||||
<screenshot>https://raw.githubusercontent.com/cbcoutinho/nextcloud-mcp-server/master/docs/images/mcp-ui-screenshot.png</screenshot>
|
<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>
|
<dependencies>
|
||||||
<nextcloud min-version="30" max-version="32"/>
|
<nextcloud min-version="30" max-version="32"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|||||||
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "1.0.0",
|
"version": "0.4.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "1.0.0",
|
"version": "0.4.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextcloud/axios": "^2.5.1",
|
"@nextcloud/axios": "^2.5.1",
|
||||||
@@ -20,12 +20,12 @@
|
|||||||
"vue-material-design-icons": "^5.3.1"
|
"vue-material-design-icons": "^5.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nextcloud/browserslist-config": "^3.0.1",
|
"@nextcloud/browserslist-config": "3.1.2",
|
||||||
"@nextcloud/eslint-config": "^8.4.2",
|
"@nextcloud/eslint-config": "8.4.2",
|
||||||
"@nextcloud/stylelint-config": "^3.1.0",
|
"@nextcloud/stylelint-config": "3.1.1",
|
||||||
"@nextcloud/vite-config": "^1.5.2",
|
"@nextcloud/vite-config": "1.7.2",
|
||||||
"terser": "^5.44.1",
|
"terser": "5.44.1",
|
||||||
"vite": "^7.1.3"
|
"vite": "7.2.7"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
|
|||||||
Vendored
+7
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "astrolabe",
|
"name": "astrolabe",
|
||||||
"version": "1.0.0",
|
"version": "0.4.4",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^22.0.0",
|
"node": "^22.0.0",
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
"vue-material-design-icons": "^5.3.1"
|
"vue-material-design-icons": "^5.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nextcloud/browserslist-config": "^3.0.1",
|
"@nextcloud/browserslist-config": "3.1.2",
|
||||||
"@nextcloud/eslint-config": "^8.4.2",
|
"@nextcloud/eslint-config": "8.4.2",
|
||||||
"@nextcloud/stylelint-config": "^3.1.0",
|
"@nextcloud/stylelint-config": "3.1.1",
|
||||||
"@nextcloud/vite-config": "^1.5.2",
|
"@nextcloud/vite-config": "1.7.2",
|
||||||
"terser": "^5.44.1",
|
"terser": "5.44.1",
|
||||||
"vite": "^7.1.3"
|
"vite": "7.2.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 736 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"nextcloud/openapi-extractor": "v1.8.2"
|
"nextcloud/openapi-extractor": "v1.8.7"
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"platform": {
|
"platform": {
|
||||||
|
|||||||
+10
-10
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "1f40f0a54fa934aa136ec78a01fdc61a",
|
"content-hash": "384d95db63f1a0aae08a0ae123ecf4bb",
|
||||||
"packages": [],
|
"packages": [],
|
||||||
"packages-dev": [
|
"packages-dev": [
|
||||||
{
|
{
|
||||||
@@ -82,16 +82,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nextcloud/openapi-extractor",
|
"name": "nextcloud/openapi-extractor",
|
||||||
"version": "v1.8.2",
|
"version": "v1.8.7",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/nextcloud-releases/openapi-extractor.git",
|
"url": "https://github.com/nextcloud-releases/openapi-extractor.git",
|
||||||
"reference": "aa4b6750b255460bec8d45406d33606863010d2e"
|
"reference": "230f61925c362779652b0038a1314ce5f931e853"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/aa4b6750b255460bec8d45406d33606863010d2e",
|
"url": "https://api.github.com/repos/nextcloud-releases/openapi-extractor/zipball/230f61925c362779652b0038a1314ce5f931e853",
|
||||||
"reference": "aa4b6750b255460bec8d45406d33606863010d2e",
|
"reference": "230f61925c362779652b0038a1314ce5f931e853",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -102,9 +102,9 @@
|
|||||||
"phpstan/phpdoc-parser": "^2.1"
|
"phpstan/phpdoc-parser": "^2.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"nextcloud/coding-standard": "^1.2",
|
"nextcloud/coding-standard": "^1.4.0",
|
||||||
"nextcloud/ocp": "dev-master",
|
"nextcloud/ocp": "dev-master",
|
||||||
"rector/rector": "^2.0"
|
"rector/rector": "^2.2.8"
|
||||||
},
|
},
|
||||||
"bin": [
|
"bin": [
|
||||||
"bin/generate-spec",
|
"bin/generate-spec",
|
||||||
@@ -123,9 +123,9 @@
|
|||||||
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
|
"description": "A tool for extracting OpenAPI specifications from Nextcloud source code",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/nextcloud-releases/openapi-extractor/issues",
|
"issues": "https://github.com/nextcloud-releases/openapi-extractor/issues",
|
||||||
"source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.8.2"
|
"source": "https://github.com/nextcloud-releases/openapi-extractor/tree/v1.8.7"
|
||||||
},
|
},
|
||||||
"time": "2025-08-26T06:28:24+00:00"
|
"time": "2025-12-02T09:52:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
@@ -243,5 +243,5 @@
|
|||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.1"
|
"php": "8.1"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
-1
Submodule third_party/oidc updated: 860a10cd05...8473b8497b
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.52.1"
|
version = "0.56.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
|||||||
Reference in New Issue
Block a user