Compare commits

..

1 Commits

Author SHA1 Message Date
smithery-ai[bot] 5cda32fa0f Update README 2025-11-23 00:25:38 +00:00
70 changed files with 1639 additions and 6956 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
packages: write
steps:
- name: Check out
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
fetch-depth: 0
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
@@ -25,7 +25,7 @@ jobs:
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
changelog_increment_filename: body.md
- name: Release
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
with:
body_path: "body.md"
tag_name: v${{ env.REVISION }}
-57
View File
@@ -1,57 +0,0 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Please review this pull request and provide feedback on:
- Code quality and best practices
- Potential bugs or issues
- Performance considerations
- Security concerns
- Test coverage
Use the repository's CLAUDE.md for guidance on style and conventions. Be constructive and helpful in your feedback.
Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR.
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
claude_args: '--allowed-tools "Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh pr list:*)"'
-50
View File
@@ -1,50 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@f0c8eb29807907de7f5412d04afceb5e24817127 # v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://docs.claude.com/en/docs/claude-code/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
+2 -2
View File
@@ -12,11 +12,11 @@ jobs:
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Docker meta
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
with:
# list of Docker images to use as base name for tags
images: |
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
with:
fetch-depth: 0
+191 -25
View File
@@ -3,10 +3,6 @@ name: RAG Evaluation
on:
workflow_dispatch:
inputs:
manual_path:
description: 'Path to Nextcloud User Manual PDF in Nextcloud'
required: false
default: 'Nextcloud Manual.pdf'
embedding_model:
description: 'OpenAI embedding model'
required: false
@@ -19,30 +15,69 @@ on:
jobs:
rag-evaluation:
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
models: read
timeout-minutes: 45
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
submodules: 'true'
- name: Clone Nextcloud documentation
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
repository: 'nextcloud/documentation'
path: 'nextcloud-docs'
- name: Install Sphinx and LaTeX dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
python3-sphinx \
python3-pip \
latexmk \
texlive-latex-recommended \
texlive-latex-extra \
texlive-fonts-recommended \
texlive-fonts-extra
- name: Build User Manual PDF
run: |
cd nextcloud-docs/user_manual
pip3 install -r ../requirements.txt
make latexpdf
ls -la _build/latex/
cp _build/latex/NextcloudUserManual.pdf ../../Nextcloud_User_Manual.pdf
echo "PDF built successfully"
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
with:
php-version: 8.4
coverage: none
- name: Install OIDC app composer dependencies
run: |
cd third_party/oidc
composer install --no-dev
###### Required to build OIDC App ######
- name: Run docker compose with vector sync
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
with:
compose-file: |
./docker-compose.yml
./docker-compose.ci.yml
compose-file: "./docker-compose.yml"
up-flags: "--build"
env:
# Environment variables passed to docker-compose.ci.yml
# Override MCP container environment for OpenAI + vector sync
VECTOR_SYNC_ENABLED: "true"
VECTOR_SYNC_SCAN_INTERVAL: "30"
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
VECTOR_SYNC_SCAN_INTERVAL: "5"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Wait for Nextcloud to be ready
run: |
@@ -65,7 +100,7 @@ jobs:
echo "Waiting for MCP server..."
max_attempts=30
attempt=0
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health/live | grep -q "200"; do
until curl -o /dev/null -s -w "%{http_code}\n" http://localhost:8000/health | grep -q "200"; do
attempt=$((attempt + 1))
if [ $attempt -ge $max_attempts ]; then
echo "MCP server did not become ready in time."
@@ -76,28 +111,159 @@ jobs:
done
echo "MCP server is ready."
- name: Upload User Manual PDF to Nextcloud
run: |
echo "Uploading Nextcloud_User_Manual.pdf to Nextcloud..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
-X PUT \
-T Nextcloud_User_Manual.pdf \
"http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "204" ]; then
echo "PDF uploaded successfully (HTTP $HTTP_CODE)"
else
echo "Failed to upload PDF (HTTP $HTTP_CODE)"
exit 1
fi
- name: Create vector-index tag
id: create_tag
run: |
# Create the tag using OCS API
echo "Creating vector-index tag..."
RESPONSE=$(curl -s -u admin:admin \
-X POST \
-H 'Content-Type: application/json' \
-H 'OCS-APIRequest: true' \
-d '{"name":"vector-index","userVisible":true,"userAssignable":true}' \
"http://localhost:8080/ocs/v2.php/apps/systemtags/api/v1/tags")
echo "Create tag response: $RESPONSE"
# Get tag ID from response or lookup
TAG_ID=$(echo "$RESPONSE" | grep -oP '(?<="id":)[0-9]+' | head -1 || echo "")
if [ -z "$TAG_ID" ]; then
echo "Tag may already exist, looking it up..."
TAG_ID=$(curl -s -u admin:admin \
-X PROPFIND \
-H 'Content-Type: application/xml' \
-d '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:id/><oc:display-name/></d:prop></d:propfind>' \
http://localhost:8080/remote.php/dav/systemtags/ \
| grep -B2 "vector-index" | grep -oP '(?<=<oc:id>)[0-9]+(?=</oc:id>)' | head -1 || echo "")
fi
if [ -z "$TAG_ID" ]; then
echo "ERROR: Could not create or find vector-index tag"
exit 1
fi
echo "Tag ID: $TAG_ID"
echo "tag_id=$TAG_ID" >> $GITHUB_OUTPUT
- name: Get file ID of uploaded PDF
id: get_file_id
run: |
echo "Getting file ID for Nextcloud_User_Manual.pdf..."
# Get file ID using PROPFIND
FILE_ID=$(curl -s -u admin:admin \
-X PROPFIND \
-H 'Content-Type: application/xml' \
-d '<?xml version="1.0"?><d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns"><d:prop><oc:fileid/></d:prop></d:propfind>' \
"http://localhost:8080/remote.php/dav/files/admin/Nextcloud_User_Manual.pdf" \
| grep -oP '(?<=<oc:fileid>)[0-9]+(?=</oc:fileid>)' || echo "")
if [ -z "$FILE_ID" ]; then
echo "ERROR: Could not find file ID"
exit 1
fi
echo "Found file ID: $FILE_ID"
echo "file_id=$FILE_ID" >> $GITHUB_OUTPUT
- name: Tag file with vector-index
env:
FILE_ID: ${{ steps.get_file_id.outputs.file_id }}
TAG_ID: ${{ steps.create_tag.outputs.tag_id }}
run: |
echo "Tagging file $FILE_ID with tag $TAG_ID..."
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin \
-X PUT \
-H 'Content-Type: application/json' \
-H 'Content-Length: 0' \
"http://localhost:8080/remote.php/dav/systemtags-relations/files/$FILE_ID/$TAG_ID")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "409" ]; then
echo "File tagged successfully (HTTP $HTTP_CODE)"
else
echo "Failed to tag file (HTTP $HTTP_CODE)"
exit 1
fi
- name: Wait for vector sync to complete indexing
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
run: |
echo "Waiting for vector sync to index the manual..."
max_attempts=60
attempt=0
# Wait for initial scan to pick up the file
sleep 10
while [ $attempt -lt $max_attempts ]; do
attempt=$((attempt + 1))
# Check vector sync status via MCP
STATUS=$(curl -s http://localhost:8000/health || echo "{}")
echo "Attempt $attempt/$max_attempts: $STATUS"
# Also check indexed count via semantic search
# If we get results, indexing is done
RESULT=$(curl -s -X POST http://localhost:8000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"nc_get_vector_sync_status","arguments":{}}}' \
2>/dev/null || echo "{}")
echo "Vector sync status: $RESULT"
# Check if pending is 0 and indexed > 0
INDEXED=$(echo "$RESULT" | jq -r '.result.structuredContent.indexed // 0' 2>/dev/null || echo "0")
PENDING=$(echo "$RESULT" | jq -r '.result.structuredContent.pending // 1' 2>/dev/null || echo "1")
echo "Indexed: $INDEXED, Pending: $PENDING"
if [ "$INDEXED" -gt "0" ] && [ "$PENDING" -eq "0" ]; then
echo "Indexing complete! $INDEXED documents indexed."
break
fi
sleep 10
done
if [ $attempt -ge $max_attempts ]; then
echo "WARNING: Indexing may not be complete, proceeding anyway..."
fi
- name: Run RAG evaluation tests
env:
NEXTCLOUD_HOST: "http://localhost:8080"
NEXTCLOUD_USERNAME: "admin"
NEXTCLOUD_PASSWORD: "admin"
RAG_MANUAL_PATH: ${{ inputs.manual_path }}
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: "https://models.github.ai/inference"
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
run: |
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
- name: Capture MCP container logs
if: always()
run: |
echo "=== MCP Container Logs ==="
docker compose logs mcp --tail=500
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
- name: Upload test results
if: always()
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
uses: actions/upload-artifact@v4
with:
name: rag-evaluation-results
path: |
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
- name: Install uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Install Python 3.11
run: uv python install 3.11
- name: Build
+6 -6
View File
@@ -9,9 +9,9 @@ jobs:
linting:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Install the latest version of uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Check format
run: |
uv run --frozen ruff format --diff
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
submodules: 'true'
@@ -35,7 +35,7 @@ jobs:
###### Required to build OIDC App ######
- name: Set up php 8.4
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
with:
php-version: 8.4
coverage: none
@@ -49,14 +49,14 @@ jobs:
- name: Run docker compose
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
with:
compose-file: "./docker-compose.yml"
#compose-flags: "--profile qdrant"
up-flags: "--build"
- name: Install the latest version of uv
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
- name: Install Playwright dependencies
run: |
-112
View File
@@ -1,115 +1,3 @@
## v0.52.0 (2025-12-13)
### Feat
- **vector**: add Deck card vector search with visualization support
## v0.51.0 (2025-12-13)
### Feat
- **vector-viz**: add news_item support for links and chunk expansion
## v0.50.2 (2025-12-13)
### Fix
- **news**: revert get_item() to use get_items() + filter
## v0.50.1 (2025-12-12)
### Fix
- Disable DNS rebinding protection for containerized deployments
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.50.0 (2025-12-11)
### Feat
- add MCP tool annotations for enhanced UX
### Fix
- address PR review feedback
## v0.49.2 (2025-12-09)
### Fix
- Update lockfile
## v0.49.1 (2025-12-09)
### Fix
- Revert mcp version <1.23
## v0.49.0 (2025-12-08)
### Feat
- **news**: add Nextcloud News app integration
### Fix
- resolve all type checking errors (8 errors fixed)
### Refactor
- **news**: simplify vector sync to fetch all items
### Perf
- **news**: use direct API endpoint for get_item()
## v0.48.6 (2025-12-03)
### Fix
- **deps**: update dependency mcp to >=1.23,<1.24
## v0.48.5 (2025-11-28)
### Fix
- **deps**: update dependency pillow to v12
## v0.48.4 (2025-11-23)
### Fix
- Add rate limit retry logic to OpenAI provider
## v0.48.3 (2025-11-23)
### Fix
- Increase MCP sampling timeout to 5 minutes for slower LLMs
## v0.48.2 (2025-11-23)
### Fix
- Share vector sync state with FastMCP session lifespan via module singleton
- Share vector sync state with FastMCP session lifespan via module singleton
## v0.48.1 (2025-11-23)
### Fix
- Use WebDAV for tag creation and add LLM-as-a-judge for RAG tests
### Refactor
- Move background tasks to server lifespan and deprecate SSE transport
## v0.48.0 (2025-11-23)
### Feat
- Add tag management methods to WebDAV client
## v0.47.0 (2025-11-23)
### Feat
-62
View File
@@ -56,68 +56,6 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
### MCP Tool Annotations (ADR-017)
**All tools MUST include annotations** following these patterns:
```python
from mcp.types import ToolAnnotations
# Read-only tools (list, search, get)
@mcp.tool(
title="Human Readable Name",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True, # Nextcloud is external to MCP server
),
)
# Create operations
@mcp.tool(
title="Create Resource",
annotations=ToolAnnotations(
idempotentHint=False, # Creates new resources each time
openWorldHint=True,
),
)
# Update operations (with etag/version control)
@mcp.tool(
title="Update Resource",
annotations=ToolAnnotations(
idempotentHint=False, # ETag changes = different inputs
openWorldHint=True,
),
)
# Delete operations
@mcp.tool(
title="Delete Resource",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Same end state if called repeatedly
openWorldHint=True,
),
)
# HTTP PUT without version control (special case)
@mcp.tool(
title="Write File",
annotations=ToolAnnotations(
idempotentHint=True, # Same content = same end state
openWorldHint=True,
),
)
```
**Key Principles**:
- **Idempotency**: Same inputs → same result. ETags change after updates, making them non-idempotent
- **Destructive**: Operations that permanently delete/overwrite data
- **Open World**: All Nextcloud tools access external service (openWorldHint=True)
- **Titles**: Use human-readable names, not snake_case function names
**See**: `docs/ADR-017-mcp-tool-annotations.md` for detailed rationale and examples
### Project Structure
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
+2 -2
View File
@@ -1,6 +1,6 @@
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+2 -2
View File
@@ -12,12 +12,12 @@
# - Per-session app password authentication
# - Multi-user support via Smithery session config
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
WORKDIR /app
# 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.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
# Install dependencies
# 1. git (required for caldav dependency from git)
+6 -4
View File
@@ -1,11 +1,12 @@
```markdown
<p align="center">
<img src="astrolabe.svg" alt="Nextcloud MCP Server" width="128" height="128">
</p>
# Nextcloud MCP Server
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
[![smithery badge](https://smithery.ai/badge/@cbcoutinho/nextcloud-mcp-server)](https://smithery.ai/server/@cbcoutinho/nextcloud-mcp-server)
[![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server)
**A production-ready MCP server that connects AI assistants to your Nextcloud instance.**
@@ -63,7 +64,7 @@ http://127.0.0.1:8000/mcp
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
@@ -81,7 +82,7 @@ http://127.0.0.1:8000/mcp
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
| **Tables** | 5 | Row operations on Nextcloud Tables |
| **Sharing** | 10+ | Create and manage shares |
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
@@ -145,7 +146,7 @@ This enables natural language queries and helps discover related content across
### Features
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
### Advanced Topics
@@ -223,3 +224,4 @@ This project is licensed under the AGPL-3.0 License. See [LICENSE](./LICENSE) fo
- [Model Context Protocol](https://github.com/modelcontextprotocol)
- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk)
- [Nextcloud](https://nextcloud.com/)
```
@@ -1,5 +0,0 @@
#!/bin/bash
set -euox pipefail
php /var/www/html/occ app:enable news
+3 -3
View File
@@ -1,9 +1,9 @@
dependencies:
- name: qdrant
repository: https://qdrant.github.io/qdrant-helm
version: 1.16.2
version: 1.16.0
- name: ollama
repository: https://otwld.github.io/ollama-helm
version: 1.35.0
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
generated: "2025-12-05T11:11:27.999374001Z"
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
generated: "2025-11-21T11:09:07.997781541Z"
+3 -3
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.52.0
appVersion: "0.52.0"
version: 0.47.0
appVersion: "0.47.0"
keywords:
- nextcloud
- mcp
@@ -27,7 +27,7 @@ annotations:
grafana_dashboard_folder: "Nextcloud MCP"
dependencies:
- name: qdrant
version: "1.16.2"
version: "1.16.0"
repository: https://qdrant.github.io/qdrant-helm
condition: qdrant.networkMode.deploySubchart
- name: ollama
-25
View File
@@ -1,25 +0,0 @@
# CI-specific overrides for RAG evaluation pipeline
# This file is used by the rag-evaluation.yml workflow to configure the MCP
# container with OpenAI/GitHub Models API for vector embeddings.
#
# Usage:
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
#
# Environment variables (set in CI workflow):
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
services:
mcp:
environment:
# OpenAI provider configuration (required for CI vector sync)
- OPENAI_API_KEY=${OPENAI_API_KEY}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
# Faster sync for CI
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
# Enable document processing for PDF parsing
- ENABLE_DOCUMENT_PROCESSING=true
+5 -5
View File
@@ -21,7 +21,7 @@ services:
restart: always
app:
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
restart: always
ports:
- 0.0.0.0:8080:80
@@ -34,7 +34,7 @@ services:
- ./app-hooks:/docker-entrypoint-hooks.d:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro
- ./third_party:/opt/apps:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -51,7 +51,7 @@ services:
retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
restart: always
volumes:
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
@@ -158,7 +158,7 @@ services:
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
command:
- "start-dev"
- "--import-realm"
@@ -245,7 +245,7 @@ services:
- smithery
qdrant:
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
restart: always
ports:
- 127.0.0.1:6333:6333 # REST API
-506
View File
@@ -1,506 +0,0 @@
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
## Status
Implemented
## Context
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
- No behavioral hints for clients about read-only, destructive, or idempotent operations
- Missing parameter descriptions for better auto-completion and inline help
- Clients cannot optimize caching, warn before destructive operations, or retry safely
### Available MCP Annotations
The MCP SDK provides three types of annotations:
#### 1. Tool Decorator Parameters
```python
@mcp.tool(
title="Human-Readable Name",
description="Tool description", # Can also come from docstring
annotations=ToolAnnotations(...),
icons=[Icon(...)] # Optional visual icons
)
```
#### 2. ToolAnnotations Behavioral Hints
```python
from mcp.types import ToolAnnotations
ToolAnnotations(
title="Alternative Title", # Decorator title takes precedence
readOnlyHint=True, # Tool doesn't modify data
destructiveHint=True, # Tool may delete/overwrite data
idempotentHint=True, # Repeated calls with same args are safe
openWorldHint=True # Interacts with external entities
)
```
#### 3. Parameter Descriptions
```python
from pydantic import Field
async def tool(
param: str = Field(description="What this parameter does"),
ctx: Context
):
```
### Idempotency Analysis
**Important**: Idempotency means calling with **the same inputs** produces the same result.
**NOT Idempotent** (different inputs each call):
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
- Different input (stale etag) → different result (error)
- **Creates**: `create_note(title="X")` → creates note 1
- Second call → creates note 2 (different result)
- **Append operations**: `append_content(id=1, text="X")` → adds X once
- Second call → adds X again (different result)
**Idempotent**:
- **Deletes**: `delete_note(id=1)` → note deleted
- Second call → 404 or success (same end state: note doesn't exist)
- Note: May return different status code, but end state is identical
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
- Second call → file still has "Hello" (same end state)
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
- **Set operations**: `set_property(id=1, value="X")` → property = X
- Second call → property still = X (same result)
- Note: Nextcloud updates with etags use version control, so not idempotent
**Read-Only** (always idempotent, never destructive):
- All list, search, get operations
## Decision
Add annotations to all 101 tools in three phases:
### Phase 1: Titles (Quick Win)
Add human-readable titles to all tools:
```python
@mcp.tool(title="Create Note")
async def nc_notes_create_note(...):
```
**Effort**: 2-3 hours
**Impact**: Immediate UX improvement
### Phase 2: ToolAnnotations (Behavioral Hints)
Add annotations based on corrected categorization:
```python
# Read-only tools
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True # Nextcloud is external to MCP server
)
)
# Delete tools (idempotent: same end state)
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True,
idempotentHint=True, # Deleting deleted item = same end state
openWorldHint=True
)
)
# Create tools (not idempotent: creates multiple items)
@mcp.tool(
title="Create Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
# Update tools with etag (not idempotent: etag changes)
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # Etag required = different inputs each time
openWorldHint=True
)
)
# Append operations (not idempotent: adds content each time)
@mcp.tool(
title="Append to Note",
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True
)
)
```
**Effort**: 4-6 hours
**Impact**: Better client behavior (caching, warnings, retry logic)
### Phase 3: Parameter Descriptions
Add Field() descriptions to parameters:
```python
from pydantic import Field
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
async def nc_notes_create_note(
title: str = Field(description="The title of the note"),
content: str = Field(description="Markdown content of the note"),
category: str = Field(description="Category or folder name for organizing"),
ctx: Context
) -> CreateNoteResponse:
```
**Effort**: 6-8 hours
**Impact**: Better auto-completion and inline help
## Tool Categorization
### Read-Only Tools (~40 tools)
**Pattern**: List, search, get operations
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_search_notes` → "Search Notes"
- `nc_webdav_list_directory` → "List Files and Directories"
- `nc_calendar_list_calendars` → "List Calendars"
- `nc_contacts_get_contact` → "Get Contact"
- `nc_semantic_search` → "Semantic Search"
- `check_logged_in` → "Check Server Login Status"
### Create Tools (~20 tools)
**Pattern**: Create new resources
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_create_note` → "Create Note"
- `nc_calendar_create_event` → "Create Calendar Event"
- `nc_contacts_create_contact` → "Create Contact"
- `deck_create_card` → "Create Kanban Card"
- `nc_tables_create_row` → "Create Table Row"
### Update Tools (~25 tools)
**Pattern**: Modify existing resources with etag
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
Examples:
- `nc_notes_update_note` → "Update Note"
- `nc_calendar_update_event` → "Update Calendar Event"
- `nc_contacts_update_contact` → "Update Contact"
- `deck_update_card` → "Update Kanban Card"
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
### Append/Accumulate Tools (~5 tools)
**Pattern**: Add content without replacing
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
Examples:
- `nc_notes_append_content` → "Append to Note"
**Rationale**: Each call adds content, changing the result = NOT idempotent.
### Delete Tools (~10 tools)
**Pattern**: Remove resources
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
Examples:
- `nc_notes_delete_note` → "Delete Note"
- `nc_webdav_delete_resource` → "Delete File or Directory"
- `nc_calendar_delete_event` → "Delete Calendar Event"
- `nc_contacts_delete_contact` → "Delete Contact"
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
### Special Cases
#### OAuth Provisioning Tools
```python
# Not read-only but requires user interaction
@mcp.tool(
title="Grant Server Access to Nextcloud",
annotations=ToolAnnotations(
readOnlyHint=False,
idempotentHint=False, # Creates new OAuth session each time
openWorldHint=True
)
)
async def provision_nextcloud_access(ctx: Context):
```
#### Semantic Search (Closed World)
```python
@mcp.tool(
title="Semantic Search",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=False # Searches only indexed Nextcloud data
)
)
async def nc_semantic_search(query: str, ctx: Context):
```
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
## Tool Priority Matrix
### Critical Priority (~2 tools)
OAuth tools required for server functionality:
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
- `check_logged_in` → "Check Server Login Status"
### High Priority (~50 tools)
Most commonly used modules:
- **Notes** (14 tools): Create, read, update, delete notes
- **WebDAV** (13 tools): File operations
- **Calendar** (15 tools): Events and todos
- **Semantic Search** (6 tools): AI-powered search
- **Contacts** (9 tools): Address book operations
### Medium Priority (~35 tools)
Secondary functionality:
- **Deck** (9 tools): Kanban boards
- **Tables** (7 tools): Structured data
- **Sharing** (5 tools): File sharing
### Low Priority (~14 tools)
Less frequently used:
- **Cookbook** (8 tools): Recipe management
- **News** (6 tools): RSS feeds
## Implementation Plan
### Week 1: Phase 1 - Titles
- Add human-readable titles to all 101 tools
- Update tool name mapping in documentation
- Manual test in MCP inspector
### Week 2: Phase 2 - ToolAnnotations (High Priority)
- Add annotations to Critical and High priority tools (~52 tools)
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
- Add unit tests validating annotation presence
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
- Complete remaining tools (~49 tools)
- Deck, Tables, Contacts, Cookbook, News
- Update tool listings in README
### Week 4: Phase 3 - Parameter Descriptions
- Add Field() descriptions to Critical/High priority tools
- Start with OAuth, Notes, WebDAV modules
- Incremental completion over time
## Benefits
### For Users
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
- **Safety**: Warnings before destructive operations
- **Better help**: Parameter descriptions in auto-completion
- **Confidence**: Know which operations are safe to retry
### For MCP Clients
- **Caching**: Cache results from read-only tools
- **Safety prompts**: Warn before destructiveHint=true
- **Retry logic**: Safely retry idempotent operations
- **UI organization**: Group by behavior (reads vs writes vs deletes)
- **Performance**: Optimize based on hints
### For Developers
- **Self-documenting**: Behavior is explicit
- **Consistency**: Standard patterns across codebase
- **Testing**: Validate annotations match implementation
- **Maintenance**: Clear expectations for new tools
## Consequences
### Positive
- Immediate UX improvement with minimal effort
- Clients can make smarter decisions
- Self-documenting code
- Follows MCP best practices
### Negative
- Initial effort to add annotations (12-15 hours total)
- Must maintain annotations when adding new tools
- Risk of incorrect annotations misleading clients
### Neutral
- Annotations are hints, not guarantees
- Clients may ignore annotations
- Backward compatible (additive change)
### Mitigations
- **Incorrect annotations**: Add tests validating behavior matches hints
- **Maintenance burden**: Add to code review checklist and tool template
- **Documentation**: Update CLAUDE.md with annotation guidelines
## Examples
### Complete Annotated Tool (Delete)
```python
from mcp.types import ToolAnnotations
from pydantic import Field
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True, # Deletes data permanently
idempotentHint=True, # Same end state (note doesn't exist)
openWorldHint=True # Nextcloud is external
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_delete_note(
note_id: int = Field(description="The ID of the note to delete permanently"),
ctx: Context
) -> DeleteNoteResponse:
"""Delete a note permanently (requires notes:write scope)"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Update)
```python
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # NOT idempotent: etag changes each update
openWorldHint=True
)
)
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_update_note(
note_id: int = Field(description="The ID of the note to update"),
title: str | None = Field(
default=None,
description="New title (omit to keep current)"
),
content: str | None = Field(
default=None,
description="New markdown content (omit to keep current)"
),
category: str | None = Field(
default=None,
description="New category/folder (omit to keep current)"
),
etag: str = Field(
description="ETag from get_note (prevents concurrent modification)"
),
ctx: Context
) -> UpdateNoteResponse:
"""Update an existing note's title, content, or category.
The etag parameter is required to prevent overwriting concurrent changes.
Get the current ETag by first calling nc_notes_get_note.
If the note has been modified since you retrieved it, the update will fail.
"""
client = await get_client(ctx)
# ... implementation ...
```
### Complete Annotated Tool (Read-Only)
```python
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True, # Doesn't modify data
openWorldHint=True # Queries Nextcloud
)
)
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_search_notes(
query: str = Field(description="Search term to match in note titles or content"),
ctx: Context
) -> SearchNotesResponse:
"""Search notes by title or content, returning id, title, and category.
This is a read-only operation that searches across all user notes.
Use nc_notes_get_note to retrieve the full content of matching notes.
"""
client = await get_client(ctx)
# ... implementation ...
```
## Testing Strategy
### Unit Tests
Add tests validating annotation presence and correctness:
```python
def test_notes_tools_have_annotations():
"""Verify all notes tools have appropriate annotations."""
tools = get_registered_tools(mcp)
# Check create tool
create_tool = tools["nc_notes_create_note"]
assert create_tool.title == "Create Note"
assert create_tool.annotations.idempotentHint is False
# Check delete tool
delete_tool = tools["nc_notes_delete_note"]
assert delete_tool.title == "Delete Note"
assert delete_tool.annotations.destructiveHint is True
assert delete_tool.annotations.idempotentHint is True
# Check read-only tool
search_tool = tools["nc_notes_search_notes"]
assert search_tool.title == "Search Notes"
assert search_tool.annotations.readOnlyHint is True
```
### Integration Tests
- Verify existing tests pass with annotations
- Manual testing in MCP inspector/client
### Documentation Updates
- Update README tool listings with new titles
- Add annotation guidelines to CLAUDE.md
- Include examples in developer documentation
## Resolved Questions
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
- **Decision**: Mark as `idempotentHint=True`
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
- **Decision**: Mark as `openWorldHint=True`
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
## Future Considerations
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
## References
- MCP Python SDK: `/home/chris/Software/python-sdk/`
- ToolAnnotations spec: `src/mcp/types.py:1247`
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
## Decision Timeline
- **Proposed**: 2025-12-11
- **Reviewed**: 2025-12-11 (Self-review during implementation)
- **Accepted**: 2025-12-11
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
-104
View File
@@ -1,104 +0,0 @@
# MCP 1.23.x DNS Rebinding Protection Fix
## Problem
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
### Root Cause
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
```python
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
transport_security = TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
)
```
### What Was Happening
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
### Why `--host 0.0.0.0` Didn't Help
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
## Solution
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
### Changes Made
Modified `nextcloud_mcp_server/app.py`:
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
2. **Updated all three FastMCP initializations**:
- OAuth mode (line 1015)
- Smithery stateless mode (line 1030)
- BasicAuth mode (line 1040)
Each now includes:
```python
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
```
## Impact
### ✅ What This Fixes
- **Kubernetes deployments**: Requests with k8s service DNS names now work
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
- **Ingress controllers**: Requests via ingress hostnames now work
### 🔒 Security Considerations
DNS rebinding protection defends against attacks where:
1. Attacker controls a DNS domain (e.g., `evil.com`)
2. DNS initially resolves to attacker's IP
3. After victim's browser caches the origin, DNS changes to victim's localhost
4. Attacker's page can now make requests to victim's localhost services
**Why it's safe to disable for this deployment:**
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
4. **Host header validation inappropriate** for multi-tenant k8s environments
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
```python
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=[
"nextcloud-mcp-server.default.svc.cluster.local:*",
"mcp.example.com:*",
# Add all your expected Host header values
]
)
```
## Testing
- ✅ Ruff linting passes
- ✅ Type checking passes (pre-existing warnings unrelated)
- ✅ Module imports successfully
- ✅ Compatible with MCP 1.23.x
## References
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
- Issue #373 (original report of k8s breakage)
- PR #382 (MCP 1.23.x upgrade)
+4 -4
View File
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
> [!IMPORTANT]
> **Status: Experimental**
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
> - RAG answer generation requires MCP client sampling support
@@ -39,9 +39,9 @@ Semantic search enables:
### Current Support
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
- **Architecture**: Multi-app plugin system ready for additional apps
- **Supported Apps**: Notes (fully implemented)
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
- **Architecture**: Multi-app plugin system ready, awaiting implementation
## System Components
+254 -222
View File
@@ -19,7 +19,6 @@ import httpx
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
from mcp.server.auth.settings import AuthSettings
from mcp.server.fastmcp import Context, FastMCP
from mcp.server.transport_security import TransportSecuritySettings
from pydantic import AnyHttpUrl
from starlette.applications import Starlette
from starlette.middleware.authentication import AuthenticationMiddleware
@@ -61,7 +60,6 @@ from nextcloud_mcp_server.server import (
configure_contacts_tools,
configure_cookbook_tools,
configure_deck_tools,
configure_news_tools,
configure_notes_tools,
configure_semantic_tools,
configure_sharing_tools,
@@ -245,25 +243,6 @@ def validate_pkce_support(discovery: dict, discovery_url: str) -> None:
click.echo(f"✓ PKCE support validated: {code_challenge_methods}")
@dataclass
class VectorSyncState:
"""
Module-level state for vector sync background tasks.
This singleton bridges the Starlette server lifespan (where background tasks run)
and FastMCP session lifespans (where MCP tools need access to the streams).
"""
document_send_stream: Optional[MemoryObjectSendStream] = None
document_receive_stream: Optional[MemoryObjectReceiveStream] = None
shutdown_event: Optional[anyio.Event] = None
scanner_wake_event: Optional[anyio.Event] = None
# Module-level singleton for vector sync state
_vector_sync_state = VectorSyncState()
@dataclass
class AppContext:
"""Application context for BasicAuth mode."""
@@ -516,7 +495,7 @@ async def load_oauth_client_credentials(
# and the authorization server will limit them to these allowed scopes.
#
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write"
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
@@ -576,15 +555,15 @@ async def load_oauth_client_credentials(
@asynccontextmanager
async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
"""
Manage application lifecycle for BasicAuth mode (FastMCP session lifespan).
Manage application lifecycle for BasicAuth mode.
Creates a single Nextcloud client with basic authentication
that is shared across all requests within a session.
that is shared across all requests.
Note: Background tasks (scanner, processor) are started at server level
in starlette_lifespan, not here. This lifespan runs per-session.
If vector sync is enabled (VECTOR_SYNC_ENABLED=true), also starts
background tasks for automatic document indexing (ADR-007).
"""
logger.info("Starting MCP session in BasicAuth mode")
logger.info("Starting MCP server in BasicAuth mode")
logger.info("Creating Nextcloud client with BasicAuth")
client = NextcloudClient.from_env()
@@ -600,20 +579,91 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
# Initialize document processors
initialize_document_processors()
# Yield client context - scanner runs at server level (starlette_lifespan)
# Include vector sync state from module singleton (set by starlette_lifespan)
try:
yield AppContext(
client=client,
storage=storage,
document_send_stream=_vector_sync_state.document_send_stream,
document_receive_stream=_vector_sync_state.document_receive_stream,
shutdown_event=_vector_sync_state.shutdown_event,
scanner_wake_event=_vector_sync_state.scanner_wake_event,
settings = get_settings()
# Check if vector sync is enabled
if settings.vector_sync_enabled:
logger.info("Vector sync enabled - starting background tasks")
# Get username from environment for BasicAuth mode
username = os.getenv("NEXTCLOUD_USERNAME")
if not username:
raise ValueError(
"NEXTCLOUD_USERNAME is required for vector sync in BasicAuth mode"
)
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
finally:
logger.info("Shutting down BasicAuth session")
await client.close()
shutdown_event = anyio.Event()
scanner_wake_event = anyio.Event()
# Start background tasks using anyio TaskGroup
async with anyio.create_task_group() as tg:
# Start scanner task
await tg.start(
scanner_task,
send_stream,
shutdown_event,
scanner_wake_event,
client,
username,
)
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
await tg.start(
processor_task,
i,
receive_stream.clone(),
shutdown_event,
client,
username,
)
logger.info(
f"Background sync tasks started: 1 scanner + {settings.vector_sync_processor_workers} processors"
)
# Yield with background tasks running
try:
yield AppContext(
client=client,
storage=storage,
document_send_stream=send_stream,
document_receive_stream=receive_stream,
shutdown_event=shutdown_event,
scanner_wake_event=scanner_wake_event,
)
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
# TaskGroup automatically cancels all tasks on exit
logger.info("Background sync tasks stopped")
await client.close()
else:
# No vector sync - simple lifecycle
try:
yield AppContext(client=client, storage=storage)
finally:
logger.info("Shutting down BasicAuth mode")
await client.close()
async def setup_oauth_config():
@@ -929,7 +979,7 @@ async def setup_oauth_config():
)
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
@@ -1017,11 +1067,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
lifespan=oauth_lifespan,
token_verifier=token_verifier,
auth=auth_settings,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
else:
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
@@ -1030,26 +1075,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# json_response=True returns plain JSON-RPC instead of SSE format,
# required for Smithery scanner compatibility
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_smithery,
json_response=True,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
)
else:
logger.info("Configuring MCP server for BasicAuth mode")
mcp = FastMCP(
"Nextcloud MCP",
lifespan=app_lifespan_basic,
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False
),
)
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
@mcp.resource("nc://capabilities")
async def nc_get_capabilities():
@@ -1068,7 +1098,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"contacts": configure_contacts_tools,
"cookbook": configure_cookbook_tools,
"deck": configure_deck_tools,
"news": configure_news_tools,
}
# If no specific apps are specified, enable all
@@ -1168,177 +1197,180 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Dynamic tool filtering enabled for OAuth mode (JWT and Bearer tokens)"
)
mcp_app = mcp.streamable_http_app()
if transport == "sse":
mcp_app = mcp.sse_app()
starlette_lifespan = None
elif transport in ("http", "streamable-http"):
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def starlette_lifespan(app: Starlette):
# Set OAuth context for OAuth login routes (ADR-004)
if oauth_enabled:
# Prepare OAuth config from setup_oauth_config closure variables
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
oauth_context_dict = {
"storage": refresh_token_storage,
"oauth_client": oauth_client,
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
"client_id": client_id, # From setup_oauth_config (DCR or static)
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
"scopes": scopes,
"nextcloud_host": nextcloud_host,
"nextcloud_resource_uri": nextcloud_resource_uri,
"oauth_provider": oauth_provider,
},
}
app.state.oauth_context = oauth_context_dict
# Also set oauth_context on browser_app for session authentication
# browser_app is in the same function scope (defined later in create_app)
# We need to find it in the mounted routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.oauth_context = oauth_context_dict
logger.info(
"OAuth context shared with browser_app for session auth"
)
break
logger.info(
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
)
else:
# BasicAuth mode - share storage with browser_app for webhook management
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
app.state.storage = storage
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.storage = storage
logger.info(
"Storage shared with browser_app for webhook management"
)
break
# Start background vector sync tasks for BasicAuth mode (ADR-007)
# Scanner runs at server-level (once), not per-session
import anyio as anyio_module
settings = get_settings()
if not oauth_enabled and settings.vector_sync_enabled:
logger.info("Starting background vector sync tasks for BasicAuth mode")
# Get username from environment
username = os.getenv("NEXTCLOUD_USERNAME")
if not username:
raise ValueError(
"NEXTCLOUD_USERNAME required for vector sync in BasicAuth mode"
@asynccontextmanager
async def starlette_lifespan(app: Starlette):
# Set OAuth context for OAuth login routes (ADR-004)
if oauth_enabled:
# Prepare OAuth config from setup_oauth_config closure variables
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
)
# Create client for vector sync (server-level, not per-session)
client = NextcloudClient.from_env()
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
app.state.document_receive_stream = receive_stream
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also store in module singleton for FastMCP session lifespans
_vector_sync_state.document_send_stream = send_stream
_vector_sync_state.document_receive_stream = receive_stream
_vector_sync_state.shutdown_event = shutdown_event
_vector_sync_state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state stored in module singleton")
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.document_send_stream = send_stream
route.app.state.document_receive_stream = receive_stream
route.app.state.shutdown_event = shutdown_event
route.app.state.scanner_wake_event = scanner_wake_event
logger.info("Vector sync state shared with browser_app for /app")
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
# Start scanner task
await tg.start(
scanner_task,
send_stream,
shutdown_event,
scanner_wake_event,
client,
username,
nextcloud_resource_uri = os.getenv(
"NEXTCLOUD_RESOURCE_URI", nextcloud_host
)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", "")
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
oauth_context_dict = {
"storage": refresh_token_storage,
"oauth_client": oauth_client,
"token_verifier": token_verifier, # For querying IdP userinfo endpoint
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
"client_id": client_id, # From setup_oauth_config (DCR or static)
"client_secret": client_secret, # From setup_oauth_config (DCR or static)
"scopes": scopes,
"nextcloud_host": nextcloud_host,
"nextcloud_resource_uri": nextcloud_resource_uri,
"oauth_provider": oauth_provider,
},
}
app.state.oauth_context = oauth_context_dict
# Also set oauth_context on browser_app for session authentication
# browser_app is in the same function scope (defined later in create_app)
# We need to find it in the mounted routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.oauth_context = oauth_context_dict
logger.info(
"OAuth context shared with browser_app for session auth"
)
break
logger.info(
f"OAuth context initialized for login routes (client_id={client_id[:16]}...)"
)
else:
# BasicAuth mode - share storage with browser_app for webhook management
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
app.state.storage = storage
# Also share with browser_app for webhook routes
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.storage = storage
logger.info(
"Storage shared with browser_app for webhook management"
)
break
# Start background vector sync tasks for BasicAuth mode (ADR-007)
# For streamable-http transport, FastMCP lifespan isn't automatically triggered
# so we manually start background tasks here if vector sync is enabled
import anyio as anyio_module
settings = get_settings()
if not oauth_enabled and settings.vector_sync_enabled:
logger.info("Starting background vector sync tasks for BasicAuth mode")
# Get username from environment
username = os.getenv("NEXTCLOUD_USERNAME")
if not username:
raise ValueError(
"NEXTCLOUD_USERNAME required for vector sync in BasicAuth mode"
)
# Get Nextcloud client from MCP app context
# Create client since we're outside FastMCP lifespan
client = NextcloudClient.from_env()
# Initialize Qdrant collection before starting background tasks
logger.info("Initializing Qdrant collection...")
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
try:
await get_qdrant_client() # Triggers collection creation if needed
logger.info("Qdrant collection ready")
except Exception as e:
logger.error(f"Failed to initialize Qdrant collection: {e}")
raise RuntimeError(
f"Cannot start vector sync - Qdrant initialization failed: {e}"
) from e
# Initialize shared state
send_stream, receive_stream = anyio_module.create_memory_object_stream(
max_buffer_size=settings.vector_sync_queue_max_size
)
shutdown_event = anyio_module.Event()
scanner_wake_event = anyio_module.Event()
# Store in app state for access from routes (ADR-007)
app.state.document_send_stream = send_stream
app.state.document_receive_stream = receive_stream
app.state.shutdown_event = shutdown_event
app.state.scanner_wake_event = scanner_wake_event
# Also share with browser_app for /app route
for route in app.routes:
if isinstance(route, Mount) and route.path == "/app":
route.app.state.document_send_stream = send_stream
route.app.state.document_receive_stream = receive_stream
route.app.state.shutdown_event = shutdown_event
route.app.state.scanner_wake_event = scanner_wake_event
logger.info(
"Vector sync state shared with browser_app for /app"
)
break
# Start background tasks using anyio TaskGroup
async with anyio_module.create_task_group() as tg:
# Start scanner task
await tg.start(
processor_task,
i,
receive_stream.clone(),
scanner_task,
send_stream,
shutdown_event,
scanner_wake_event,
client,
username,
)
logger.info(
f"Background sync tasks started: 1 scanner + "
f"{settings.vector_sync_processor_workers} processors"
)
# Start processor pool (each gets a cloned receive stream)
for i in range(settings.vector_sync_processor_workers):
await tg.start(
processor_task,
i,
receive_stream.clone(),
shutdown_event,
client,
username,
)
# Run MCP session manager and yield
logger.info(
f"Background sync tasks started: 1 scanner + "
f"{settings.vector_sync_processor_workers} processors"
)
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
try:
yield
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
await client.close()
# TaskGroup automatically cancels all tasks on exit
else:
# No vector sync - just run MCP session manager
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
try:
yield
finally:
# Shutdown signal
logger.info("Shutting down background sync tasks")
shutdown_event.set()
await client.close()
# TaskGroup automatically cancels all tasks on exit
else:
# No vector sync - just run MCP session manager
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
yield
yield
# Health check endpoints for Kubernetes probes
def health_live(request):
@@ -201,15 +201,8 @@ function vizApp() {
return `${baseUrl}/apps/calendar`;
case 'contact':
return `${baseUrl}/apps/contacts`;
case 'deck_card':
// URL pattern: /apps/deck/board/:boardId/card/:cardId
if (result.metadata && result.metadata.board_id) {
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
}
// Fallback if board_id not available
case 'deck':
return `${baseUrl}/apps/deck`;
case 'news_item':
return `${baseUrl}/apps/news/item/${result.id}`;
default:
return `${baseUrl}`;
}
@@ -65,12 +65,8 @@
<span>Contacts</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
<span>Deck Cards</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
<span>News</span>
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
<span>Deck</span>
</label>
</div>
</div>
@@ -303,13 +303,10 @@ class UnifiedTokenVerifier(TokenVerifier):
try:
# Introspection requires client authentication
client_id = self.settings.oidc_client_id
client_secret = self.settings.oidc_client_secret
assert client_id is not None and client_secret is not None
response = await self.http_client.post(
self.introspection_uri,
data={"token": token},
auth=(client_id, client_secret),
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
)
if response.status_code == 200:
+39 -78
View File
@@ -22,7 +22,6 @@ from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search import (
BM25HybridSearchAlgorithm,
SemanticSearchAlgorithm,
@@ -140,10 +139,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
_get_authenticated_client_for_userinfo,
)
with trace_operation("vector_viz.get_auth_client"):
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
async with auth_client_ctx as nc_client: # noqa: F841
async with await _get_authenticated_client_for_userinfo(request) as nc_client: # noqa: F841
# Create search algorithm (no client needed - verification removed)
if algorithm == "semantic":
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
@@ -163,40 +159,24 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
all_results = []
if doc_types is None or len(doc_types) == 0:
# Cross-app search - search all indexed types
with trace_operation(
"vector_viz.search_execute",
attributes={
"search.algorithm": algorithm,
"search.limit": limit * 2,
"search.doc_type": "all",
},
):
unverified_results = await search_algo.search(
query=query,
user_id=username,
limit=limit * 2, # Buffer for verification filtering
doc_type=None, # Search all types
score_threshold=score_threshold,
)
unverified_results = await search_algo.search(
query=query,
user_id=username,
limit=limit * 2, # Buffer for verification filtering
doc_type=None, # Search all types
score_threshold=score_threshold,
)
all_results.extend(unverified_results)
else:
# Search each document type and combine
for doc_type in doc_types:
with trace_operation(
"vector_viz.search_execute",
attributes={
"search.algorithm": algorithm,
"search.limit": limit * 2,
"search.doc_type": doc_type,
},
):
unverified_results = await search_algo.search(
query=query,
user_id=username,
limit=limit * 2, # Buffer for verification filtering
doc_type=doc_type,
score_threshold=score_threshold,
)
unverified_results = await search_algo.search(
query=query,
user_id=username,
limit=limit * 2, # Buffer for verification filtering
doc_type=doc_type,
score_threshold=score_threshold,
)
all_results.extend(unverified_results)
# Sort by score before verification
all_results.sort(key=lambda r: r.score, reverse=True)
@@ -210,26 +190,22 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
# Store original scores and normalize for visualization
# (best result = 1.0, worst result = 0.0 within THIS result set)
# This makes visual encoding meaningful regardless of RRF normalization
with trace_operation(
"vector_viz.score_normalize",
attributes={"normalize.num_results": len(search_results)},
):
if search_results:
scores = [r.score for r in search_results]
min_score, max_score = min(scores), max(scores)
score_range = max_score - min_score if max_score > min_score else 1.0
if search_results:
scores = [r.score for r in search_results]
min_score, max_score = min(scores), max(scores)
score_range = max_score - min_score if max_score > min_score else 1.0
logger.info(
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
f"→ [0.0, 1.0]"
)
logger.info(
f"Normalizing scores for viz: original range [{min_score:.3f}, {max_score:.3f}] "
f"→ [0.0, 1.0]"
)
# Store original score and rescale to 0-1 for visualization
for r in search_results:
# Store original score before normalization
r.original_score = r.score
# Rescale for visual encoding
r.score = (r.score - min_score) / score_range
# Store original score and rescale to 0-1 for visualization
for r in search_results:
# Store original score before normalization
r.original_score = r.score
# Rescale for visual encoding
r.score = (r.score - min_score) / score_range
if not search_results:
return JSONResponse(
@@ -244,9 +220,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
# Fetch vectors for specific matching chunks from Qdrant using batch retrieve
vector_fetch_start = time.perf_counter()
with trace_operation("vector_viz.get_qdrant_client"):
qdrant_client = await get_qdrant_client()
qdrant_client = await get_qdrant_client()
chunk_vectors_map = {} # Map (doc_id, chunk_start, chunk_end) -> vector
@@ -257,16 +231,12 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
if point_ids:
# Single batch retrieve call instead of N sequential scroll calls
# This is ~50x faster for 50 results (1 HTTP request vs 50)
with trace_operation(
"vector_viz.vector_retrieve",
attributes={"retrieve.num_points": len(point_ids)},
):
points_response = await qdrant_client.retrieve(
collection_name=settings.get_collection_name(),
ids=point_ids,
with_vectors=["dense"],
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
)
points_response = await qdrant_client.retrieve(
collection_name=settings.get_collection_name(),
ids=point_ids,
with_vectors=["dense"],
with_payload=["doc_id", "chunk_start_offset", "chunk_end_offset"],
)
# Build chunk_vectors_map from batch response
for point in points_response:
@@ -298,7 +268,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
"title": r.title,
"excerpt": r.excerpt,
"score": r.score,
"metadata": r.metadata,
}
for r in search_results
],
@@ -398,16 +367,9 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
import anyio
with trace_operation(
"vector_viz.pca_compute",
attributes={
"pca.num_vectors": len(all_vectors_normalized),
"pca.embedding_dim": embedding_dim,
},
):
coords_3d, pca = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
lambda: _compute_pca(all_vectors_normalized)
)
coords_3d, pca = await anyio.to_thread.run_sync( # type: ignore[attr-defined]
lambda: _compute_pca(all_vectors_normalized)
)
pca_duration = time.perf_counter() - pca_start
# After fit, these attributes are guaranteed to be set
@@ -459,7 +421,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
), # Raw score from algorithm
"chunk_start_offset": r.chunk_start_offset,
"chunk_end_offset": r.chunk_end_offset,
"metadata": r.metadata, # Include metadata (e.g., board_id for deck_card)
}
for r in search_results
]
@@ -139,7 +139,6 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
raise RuntimeError("BasicAuth credentials not configured")
assert nextcloud_host is not None # Type narrowing for type checker
assert username is not None and password is not None # Type narrowing
return httpx.AsyncClient(
base_url=nextcloud_host,
auth=(username, password),
+2 -2
View File
@@ -29,9 +29,9 @@ from .app import get_app
@click.option(
"--transport",
"-t",
default="streamable-http",
default="sse",
show_default=True,
type=click.Choice(["streamable-http", "http"]),
type=click.Choice(["sse", "streamable-http", "http"]),
help="MCP transport protocol",
)
@click.option(
-2
View File
@@ -18,7 +18,6 @@ from .contacts import ContactsClient
from .cookbook import CookbookClient
from .deck import DeckClient
from .groups import GroupsClient
from .news import NewsClient
from .notes import NotesClient
from .sharing import SharingClient
from .tables import TablesClient
@@ -82,7 +81,6 @@ class NextcloudClient:
self.contacts = ContactsClient(self._client, username)
self.cookbook = CookbookClient(self._client, username)
self.deck = DeckClient(self._client, username)
self.news = NewsClient(self._client, username)
self.users = UsersClient(self._client, username)
self.groups = GroupsClient(self._client, username)
self.sharing = SharingClient(self._client, username)
-394
View File
@@ -1,394 +0,0 @@
"""Client for Nextcloud News app operations."""
import logging
from enum import IntEnum
from typing import Any
from .base import BaseNextcloudClient
logger = logging.getLogger(__name__)
class NewsItemType(IntEnum):
"""Type constants for News API item queries."""
FEED = 0 # Single feed
FOLDER = 1 # Folder and its feeds
STARRED = 2 # All starred items
ALL = 3 # All items
class NewsClient(BaseNextcloudClient):
"""Client for Nextcloud News app operations."""
app_name = "news"
API_BASE = "/apps/news/api/v1-3"
# --- Folders ---
async def get_folders(self) -> list[dict[str, Any]]:
"""Get all folders."""
response = await self._make_request("GET", f"{self.API_BASE}/folders")
return response.json().get("folders", [])
async def create_folder(self, name: str) -> dict[str, Any]:
"""Create a new folder.
Args:
name: Folder name
Returns:
Created folder data
Raises:
HTTPStatusError: 409 if folder name already exists,
422 if name is empty
"""
response = await self._make_request(
"POST", f"{self.API_BASE}/folders", json={"name": name}
)
folders = response.json().get("folders", [])
return folders[0] if folders else {}
async def rename_folder(self, folder_id: int, name: str) -> None:
"""Rename a folder.
Args:
folder_id: Folder ID
name: New folder name
Raises:
HTTPStatusError: 404 if folder not found, 409 if name exists
"""
await self._make_request(
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
)
async def delete_folder(self, folder_id: int) -> None:
"""Delete a folder and all its feeds/items.
Args:
folder_id: Folder ID
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
"""Mark all items in a folder as read.
Args:
folder_id: Folder ID
newest_item_id: ID of newest item to mark read (prevents marking
items user hasn't seen yet)
Raises:
HTTPStatusError: 404 if folder not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/folders/{folder_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Feeds ---
async def get_feeds(self) -> dict[str, Any]:
"""Get all feeds with metadata.
Returns:
Dict with keys:
- feeds: List of feed objects
- starredCount: Number of starred items
- newestItemId: ID of newest item (omitted if no items)
"""
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
return response.json()
async def create_feed(
self, url: str, folder_id: int | None = None
) -> dict[str, Any]:
"""Subscribe to a new feed.
Args:
url: Feed URL
folder_id: Optional folder ID (None for root)
Returns:
Created feed data
Raises:
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
"""
body: dict[str, Any] = {"url": url}
if folder_id is not None:
body["folderId"] = folder_id
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
data = response.json()
feeds = data.get("feeds", [])
return feeds[0] if feeds else {}
async def delete_feed(self, feed_id: int) -> None:
"""Unsubscribe from a feed (deletes all items).
Args:
feed_id: Feed ID
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
"""Move a feed to a different folder.
Args:
feed_id: Feed ID
folder_id: Target folder ID (None for root)
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/move",
json={"folderId": folder_id},
)
async def rename_feed(self, feed_id: int, title: str) -> None:
"""Rename a feed.
Args:
feed_id: Feed ID
title: New feed title
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/rename",
json={"feedTitle": title},
)
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
"""Mark all items in a feed as read.
Args:
feed_id: Feed ID
newest_item_id: ID of newest item to mark read
Raises:
HTTPStatusError: 404 if feed not found
"""
await self._make_request(
"POST",
f"{self.API_BASE}/feeds/{feed_id}/read",
json={"newestItemId": newest_item_id},
)
# --- Items ---
async def get_items(
self,
batch_size: int = 50,
offset: int = 0,
type_: int = NewsItemType.ALL,
id_: int = 0,
get_read: bool = True,
oldest_first: bool = False,
) -> list[dict[str, Any]]:
"""Get items (articles) with filtering.
Args:
batch_size: Number of items to return (-1 for all)
offset: Item ID to start after (for pagination)
type_: Item type filter (NewsItemType)
id_: Feed/folder ID (ignored for STARRED/ALL types)
get_read: Include read items
oldest_first: Sort oldest first instead of newest
Returns:
List of item objects
"""
params: dict[str, Any] = {
"batchSize": batch_size,
"offset": offset,
"type": type_,
"id": id_,
"getRead": str(get_read).lower(),
"oldestFirst": str(oldest_first).lower(),
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items", params=params
)
return response.json().get("items", [])
async def get_item(self, item_id: int) -> dict[str, Any]:
"""Get a specific item by ID.
Note: The News API doesn't have a direct single-item endpoint,
so we fetch all items and filter. For efficiency, consider
caching or using get_items with specific feed if known.
Args:
item_id: Item ID
Returns:
Item data
Raises:
ValueError: If item not found
"""
# Fetch all items and find the one we need
# This is inefficient but the API doesn't provide a direct endpoint
items = await self.get_items(batch_size=-1, get_read=True)
for item in items:
if item.get("id") == item_id:
return item
raise ValueError(f"Item {item_id} not found")
async def get_updated_items(
self,
last_modified: int,
type_: int = NewsItemType.ALL,
id_: int = 0,
) -> list[dict[str, Any]]:
"""Get items modified since a timestamp (for delta sync).
Args:
last_modified: Unix timestamp (seconds or microseconds)
type_: Item type filter
id_: Feed/folder ID
Returns:
List of modified items (includes deleted items)
"""
params: dict[str, Any] = {
"lastModified": last_modified,
"type": type_,
"id": id_,
}
response = await self._make_request(
"GET", f"{self.API_BASE}/items/updated", params=params
)
return response.json().get("items", [])
async def mark_item_read(self, item_id: int) -> None:
"""Mark a single item as read.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
async def mark_item_unread(self, item_id: int) -> None:
"""Mark a single item as unread.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
async def star_item(self, item_id: int) -> None:
"""Star (favorite) a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
async def unstar_item(self, item_id: int) -> None:
"""Unstar a single item.
Args:
item_id: Item ID
Raises:
HTTPStatusError: 404 if item not found
"""
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
async def mark_items_read(self, item_ids: list[int]) -> None:
"""Mark multiple items as read.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
)
async def mark_items_unread(self, item_ids: list[int]) -> None:
"""Mark multiple items as unread.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unread/multiple",
json={"itemIds": item_ids},
)
async def star_items(self, item_ids: list[int]) -> None:
"""Star multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
)
async def unstar_items(self, item_ids: list[int]) -> None:
"""Unstar multiple items.
Args:
item_ids: List of item IDs
"""
await self._make_request(
"POST",
f"{self.API_BASE}/items/unstar/multiple",
json={"itemIds": item_ids},
)
async def mark_all_read(self, newest_item_id: int) -> None:
"""Mark all items as read.
Args:
newest_item_id: ID of newest item to mark read
"""
await self._make_request(
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
)
# --- Status ---
async def get_status(self) -> dict[str, Any]:
"""Get News app status and configuration.
Returns:
Dict with version and warnings
"""
response = await self._make_request("GET", f"{self.API_BASE}/status")
return response.json()
async def get_version(self) -> str:
"""Get News app version.
Returns:
Version string (e.g., "25.0.0")
"""
response = await self._make_request("GET", f"{self.API_BASE}/version")
return response.json().get("version", "")
+1 -239
View File
@@ -1174,9 +1174,7 @@ class WebDAVClient(BaseNextcloudClient):
if display_name_elem is not None and display_name_elem.text == tag_name:
tag_info = {
"id": int(tag_id_elem.text)
if tag_id_elem is not None and tag_id_elem.text is not None
else None,
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
"name": display_name_elem.text,
"userVisible": user_visible_elem.text.lower() == "true"
if user_visible_elem is not None
@@ -1297,239 +1295,3 @@ class WebDAVClient(BaseNextcloudClient):
logger.debug(f"Found {len(files)} files with tag ID {tag_id}")
return files
async def get_file_info(self, path: str) -> dict[str, Any] | None:
"""Get file info including file ID via WebDAV PROPFIND.
Args:
path: Path to the file (relative to user's files directory)
Returns:
File info dictionary with id, name, size, content_type, etc.
Returns None if file not found.
"""
webdav_path = f"{self._get_webdav_base_path()}/{path.lstrip('/')}"
propfind_body = """<?xml version="1.0"?>
<d:propfind xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:prop>
<oc:fileid/>
<d:displayname/>
<d:getcontentlength/>
<d:getcontenttype/>
<d:getlastmodified/>
<d:getetag/>
<d:resourcetype/>
</d:prop>
</d:propfind>"""
try:
response = await self._client.request(
"PROPFIND",
webdav_path,
headers={"Depth": "0"},
content=propfind_body,
)
response.raise_for_status()
except HTTPStatusError as e:
if e.response.status_code == 404:
logger.debug(f"File not found: {path}")
return None
raise
# Parse XML response
root = ET.fromstring(response.content)
ns = {
"d": "DAV:",
"oc": "http://owncloud.org/ns",
}
response_elem = root.find("d:response", ns)
if response_elem is None:
return None
propstat = response_elem.find("d:propstat", ns)
if propstat is None:
return None
prop = propstat.find("d:prop", ns)
if prop is None:
return None
# Extract properties
fileid_elem = prop.find("oc:fileid", ns)
displayname_elem = prop.find("d:displayname", ns)
contentlength_elem = prop.find("d:getcontentlength", ns)
contenttype_elem = prop.find("d:getcontenttype", ns)
lastmodified_elem = prop.find("d:getlastmodified", ns)
etag_elem = prop.find("d:getetag", ns)
resourcetype_elem = prop.find("d:resourcetype", ns)
is_directory = (
resourcetype_elem is not None
and resourcetype_elem.find("d:collection", ns) is not None
)
file_info = {
"id": int(fileid_elem.text)
if fileid_elem is not None and fileid_elem.text is not None
else None,
"path": path,
"name": displayname_elem.text
if displayname_elem is not None
else path.split("/")[-1],
"size": int(contentlength_elem.text)
if contentlength_elem is not None and contentlength_elem.text
else 0,
"content_type": contenttype_elem.text
if contenttype_elem is not None
else "",
"last_modified": lastmodified_elem.text
if lastmodified_elem is not None
else None,
"etag": etag_elem.text.strip('"')
if etag_elem is not None and etag_elem.text
else None,
"is_directory": is_directory,
}
logger.debug(f"Got file info for '{path}': id={file_info['id']}")
return file_info
async def create_tag(
self,
name: str,
user_visible: bool = True,
user_assignable: bool = True,
) -> dict[str, Any]:
"""Create a system tag via WebDAV.
Args:
name: Name of the tag to create
user_visible: Whether the tag is visible to users
user_assignable: Whether users can assign this tag
Returns:
Tag dictionary with id, name, userVisible, userAssignable
Raises:
HTTPStatusError: If tag creation fails (409 if already exists)
"""
# Use WebDAV POST with JSON body to create tag
response = await self._client.post(
"/remote.php/dav/systemtags/",
headers={"Content-Type": "application/json"},
json={
"name": name,
"userVisible": user_visible,
"userAssignable": user_assignable,
},
)
response.raise_for_status()
# Extract tag ID from Content-Location header (e.g., /remote.php/dav/systemtags/42)
content_location = response.headers.get("Content-Location", "")
tag_id = None
if content_location:
# Extract the numeric ID from the path
try:
tag_id = int(content_location.rstrip("/").split("/")[-1])
except (ValueError, IndexError):
pass
tag_info = {
"id": tag_id,
"name": name,
"userVisible": user_visible,
"userAssignable": user_assignable,
}
logger.info(f"Created tag '{name}' with ID {tag_info['id']}")
return tag_info
async def get_or_create_tag(
self,
name: str,
user_visible: bool = True,
user_assignable: bool = True,
) -> dict[str, Any]:
"""Get a tag by name, creating it if it doesn't exist.
Args:
name: Name of the tag
user_visible: Whether the tag is visible to users (for creation)
user_assignable: Whether users can assign this tag (for creation)
Returns:
Tag dictionary with id, name, userVisible, userAssignable
"""
# First try to get existing tag
existing_tag = await self.get_tag_by_name(name)
if existing_tag:
logger.debug(f"Tag '{name}' already exists with ID {existing_tag['id']}")
return existing_tag
# Create new tag
try:
return await self.create_tag(name, user_visible, user_assignable)
except HTTPStatusError as e:
if e.response.status_code == 409:
# Tag was created between our check and creation, fetch it
existing_tag = await self.get_tag_by_name(name)
if existing_tag:
return existing_tag
raise
async def assign_tag_to_file(self, file_id: int, tag_id: int) -> bool:
"""Assign a system tag to a file.
Args:
file_id: Numeric file ID
tag_id: Numeric tag ID
Returns:
True if tag was assigned successfully (or already assigned)
Raises:
HTTPStatusError: If tag assignment fails
"""
response = await self._client.request(
"PUT",
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
headers={"Content-Length": "0"},
content=b"",
)
# 201 = Created (new assignment), 409 = Conflict (already assigned)
if response.status_code in (201, 409):
logger.info(f"Tagged file {file_id} with tag {tag_id}")
return True
response.raise_for_status()
return True
async def remove_tag_from_file(self, file_id: int, tag_id: int) -> bool:
"""Remove a system tag from a file.
Args:
file_id: Numeric file ID
tag_id: Numeric tag ID
Returns:
True if tag was removed successfully (or wasn't assigned)
Raises:
HTTPStatusError: If tag removal fails
"""
response = await self._client.request(
"DELETE",
f"/remote.php/dav/systemtags-relations/files/{file_id}/{tag_id}",
)
# 204 = No Content (removed), 404 = Not Found (wasn't assigned)
if response.status_code in (204, 404):
logger.info(f"Removed tag {tag_id} from file {file_id}")
return True
response.raise_for_status()
return True
-170
View File
@@ -1,170 +0,0 @@
"""Pydantic models for Nextcloud News app responses."""
from typing import List
from pydantic import BaseModel, ConfigDict, Field
from .base import BaseResponse
class NewsFolder(BaseModel):
"""Model for a News folder."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Folder ID")
name: str = Field(description="Folder name")
class NewsFeed(BaseModel):
"""Model for a News feed (RSS/Atom subscription)."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Feed ID")
url: str = Field(description="Feed URL")
title: str = Field(description="Feed title")
favicon_link: str | None = Field(
None, alias="faviconLink", description="Favicon URL"
)
link: str | None = Field(None, description="Website link")
added: int = Field(description="Unix timestamp when feed was added")
folder_id: int | None = Field(
None, alias="folderId", description="Parent folder ID"
)
unread_count: int = Field(
0, alias="unreadCount", description="Number of unread items"
)
ordering: int = Field(
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
)
pinned: bool = Field(False, description="Whether feed is pinned to top")
update_error_count: int = Field(
0, alias="updateErrorCount", description="Consecutive update failures"
)
last_update_error: str | None = Field(
None, alias="lastUpdateError", description="Last update error message"
)
@property
def has_errors(self) -> bool:
"""Check if feed has update errors."""
return self.update_error_count > 0
class NewsItem(BaseModel):
"""Model for a News item (article) with full content."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Item ID")
guid: str = Field(description="Globally unique identifier")
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
url: str | None = Field(None, description="Article URL")
title: str = Field(description="Article title")
author: str | None = Field(None, description="Article author")
pub_date: int | None = Field(
None, alias="pubDate", description="Publication timestamp"
)
body: str | None = Field(None, description="Article content (HTML)")
enclosure_mime: str | None = Field(
None, alias="enclosureMime", description="Enclosure MIME type"
)
enclosure_link: str | None = Field(
None, alias="enclosureLink", description="Enclosure URL"
)
media_thumbnail: str | None = Field(
None, alias="mediaThumbnail", description="Media thumbnail URL"
)
media_description: str | None = Field(
None, alias="mediaDescription", description="Media description"
)
feed_id: int = Field(alias="feedId", description="Parent feed ID")
unread: bool = Field(True, description="Whether item is unread")
starred: bool = Field(False, description="Whether item is starred")
rtl: bool = Field(False, description="Right-to-left text")
last_modified: int = Field(
alias="lastModified", description="Last modification timestamp"
)
fingerprint: str | None = Field(
None, description="Content fingerprint for deduplication"
)
content_hash: str | None = Field(
None, alias="contentHash", description="Content hash"
)
class NewsItemSummary(BaseModel):
"""Lightweight model for News item list responses."""
model_config = ConfigDict(populate_by_name=True)
id: int = Field(description="Item ID")
title: str = Field(description="Article title")
feed_id: int = Field(alias="feedId", description="Parent feed ID")
unread: bool = Field(True, description="Whether item is unread")
starred: bool = Field(False, description="Whether item is starred")
pub_date: int | None = Field(
None, alias="pubDate", description="Publication timestamp"
)
url: str | None = Field(None, description="Article URL")
author: str | None = Field(None, description="Article author")
class NewsStatus(BaseModel):
"""Model for News app status."""
version: str = Field(description="News app version")
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
# --- Response Models ---
class ListFoldersResponse(BaseResponse):
"""Response model for listing folders."""
results: List[NewsFolder] = Field(description="List of folders")
total_count: int = Field(description="Total number of folders")
class ListFeedsResponse(BaseResponse):
"""Response model for listing feeds."""
results: List[NewsFeed] = Field(description="List of feeds")
starred_count: int = Field(0, description="Number of starred items")
newest_item_id: int | None = Field(None, description="ID of newest item")
total_count: int = Field(description="Total number of feeds")
class ListItemsResponse(BaseResponse):
"""Response model for listing items."""
results: List[NewsItemSummary] = Field(description="List of items")
total_count: int = Field(description="Number of items returned")
has_more: bool = Field(False, description="Whether more items exist")
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
class GetItemResponse(BaseResponse):
"""Response model for getting a single item."""
item: NewsItem = Field(description="Full item details")
class FeedHealthResponse(BaseResponse):
"""Response model for feed health status."""
feed_id: int = Field(description="Feed ID")
title: str = Field(description="Feed title")
url: str = Field(description="Feed URL")
has_errors: bool = Field(description="Whether feed has update errors")
error_count: int = Field(description="Number of consecutive errors")
last_error: str | None = Field(None, description="Last error message")
class GetStatusResponse(BaseResponse):
"""Response model for app status."""
version: str = Field(description="News app version")
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
def add_fields(
self,
log_data: dict[str, Any],
log_record: dict[str, Any],
record: logging.LogRecord,
message_dict: dict[str, Any],
) -> None:
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
Add custom fields to the log record, including trace context.
Args:
log_data: Dictionary to be serialized as JSON
log_record: Dictionary to be serialized as JSON
record: LogRecord instance
message_dict: Dictionary of extra fields from log call
"""
# Call parent to add standard fields
super().add_fields(log_data, record, message_dict)
super().add_fields(log_record, record, message_dict)
# Add trace context if available
trace_context = get_trace_context()
if trace_context:
log_data["trace_id"] = trace_context.get("trace_id")
log_data["span_id"] = trace_context.get("span_id")
log_record["trace_id"] = trace_context.get("trace_id")
log_record["span_id"] = trace_context.get("span_id")
# Add standard fields with consistent naming
log_data["timestamp"] = self.formatTime(record)
log_data["level"] = record.levelname
log_data["logger"] = record.name
log_data["message"] = record.getMessage()
log_record["timestamp"] = self.formatTime(record)
log_record["level"] = record.levelname
log_record["logger"] = record.name
log_record["message"] = record.getMessage()
# Include exception info if present
if record.exc_info:
log_data["exception"] = self.formatException(record.exc_info)
log_record["exception"] = self.formatException(record.exc_info)
class TraceContextTextFormatter(logging.Formatter):
+4 -6
View File
@@ -17,20 +17,18 @@ class AnthropicProvider(Provider):
Note: Anthropic doesn't provide embedding models, only text generation.
"""
def __init__(
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
):
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
"""
Initialize Anthropic provider.
Args:
api_key: Anthropic API key
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
model: Model name (e.g., "claude-3-5-sonnet-20241022")
"""
self.client = AsyncAnthropic(api_key=api_key)
self.model = generation_model
self.model = model
logger.info(f"Initialized Anthropic provider (model={self.model})")
logger.info(f"Initialized Anthropic provider (model={model})")
@property
def supports_embeddings(self) -> bool:
+9 -53
View File
@@ -7,48 +7,13 @@ Supports:
"""
import logging
from functools import wraps
import anyio
from openai import AsyncOpenAI, RateLimitError
from openai import AsyncOpenAI
from .base import Provider
logger = logging.getLogger(__name__)
# Rate limit retry configuration
MAX_RETRIES = 5
INITIAL_RETRY_DELAY = 2.0 # seconds
MAX_RETRY_DELAY = 60.0 # seconds
def retry_on_rate_limit(func):
"""Decorator to retry on OpenAI rate limit errors with exponential backoff."""
@wraps(func)
async def wrapper(*args, **kwargs):
retry_delay = INITIAL_RETRY_DELAY
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
return await func(*args, **kwargs)
except RateLimitError as e:
last_error = e
if attempt < MAX_RETRIES:
logger.warning(
f"Rate limit hit (attempt {attempt}/{MAX_RETRIES}), "
f"retrying in {retry_delay:.1f}s..."
)
await anyio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
logger.error(f"Rate limit exceeded after {MAX_RETRIES} attempts")
raise last_error # type: ignore[misc]
return wrapper
# Well-known embedding dimensions for OpenAI models
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
"text-embedding-3-small": 1536,
@@ -121,7 +86,6 @@ class OpenAIProvider(Provider):
"""Whether this provider supports text generation."""
return self.generation_model is not None
@retry_on_rate_limit
async def embed(self, text: str) -> list[float]:
"""
Generate embedding vector for text.
@@ -140,7 +104,6 @@ class OpenAIProvider(Provider):
"Embedding not supported - no embedding_model configured"
)
assert self.embedding_model is not None # Type narrowing
response = await self.client.embeddings.create(
input=text,
model=self.embedding_model,
@@ -188,8 +151,14 @@ class OpenAIProvider(Provider):
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
# Use helper method with retry logic for each batch
batch_embeddings = await self._embed_batch_request(batch)
response = await self.client.embeddings.create(
input=batch,
model=self.embedding_model,
)
# Sort by index to maintain order
sorted_data = sorted(response.data, key=lambda x: x.index)
batch_embeddings = [item.embedding for item in sorted_data]
all_embeddings.extend(batch_embeddings)
# Update dimension if not set
@@ -202,18 +171,6 @@ class OpenAIProvider(Provider):
return all_embeddings
@retry_on_rate_limit
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
"""Make a single batch embedding request with retry logic."""
assert self.embedding_model is not None # Type narrowing
response = await self.client.embeddings.create(
input=batch,
model=self.embedding_model,
)
# Sort by index to maintain order
sorted_data = sorted(response.data, key=lambda x: x.index)
return [item.embedding for item in sorted_data]
def get_dimension(self) -> int:
"""
Get embedding dimension.
@@ -237,7 +194,6 @@ class OpenAIProvider(Provider):
)
return self._dimension
@retry_on_rate_limit
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
"""
Generate text from a prompt.
+2 -2
View File
@@ -108,8 +108,8 @@ async def get_indexed_doc_types(user_id: str) -> set[str]:
with_vectors=False, # Don't need vectors for type discovery
)
doc_types: set[str] = {
str(point.payload.get("doc_type"))
doc_types = {
point.payload.get("doc_type")
for point in scroll_results
if point.payload.get("doc_type")
}
+67 -92
View File
@@ -9,7 +9,6 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
from nextcloud_mcp_server.observability.metrics import record_qdrant_operation
from nextcloud_mcp_server.observability.tracing import trace_operation
from nextcloud_mcp_server.search.algorithms import SearchAlgorithm, SearchResult
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -100,19 +99,15 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
)
# Generate dense embedding for semantic search
with trace_operation("search.get_embedding_service"):
embedding_service = get_embedding_service()
with trace_operation("search.dense_embedding"):
dense_embedding = await embedding_service.embed(query)
embedding_service = get_embedding_service()
dense_embedding = await embedding_service.embed(query)
# Store for reuse by callers (e.g., viz_routes PCA visualization)
self.query_embedding = dense_embedding
logger.debug(f"Generated dense embedding (dimension={len(dense_embedding)})")
# Generate sparse embedding for BM25 keyword search
with trace_operation("search.get_bm25_service"):
bm25_service = get_bm25_service()
with trace_operation("search.sparse_embedding_bm25"):
sparse_embedding = await bm25_service.encode_async(query)
bm25_service = get_bm25_service()
sparse_embedding = await bm25_service.encode_async(query)
logger.debug(
f"Generated sparse embedding "
f"({len(sparse_embedding['indices'])} non-zero terms)"
@@ -139,44 +134,38 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
query_filter = Filter(must=filter_conditions)
# Execute hybrid search with Qdrant native RRF fusion
with trace_operation("search.get_qdrant_client"):
qdrant_client = await get_qdrant_client()
qdrant_client = await get_qdrant_client()
try:
# Use prefetch to run both dense and sparse searches
# Qdrant will automatically merge results using RRF
with trace_operation(
"search.qdrant_query",
attributes={"query.limit": limit * 2, "query.fusion": self.fusion_name},
):
search_response = await qdrant_client.query_points(
collection_name=settings.get_collection_name(),
prefetch=[
# Dense semantic search
models.Prefetch(
query=dense_embedding,
using="dense",
limit=limit * 2, # Get extra for deduplication
filter=query_filter,
search_response = await qdrant_client.query_points(
collection_name=settings.get_collection_name(),
prefetch=[
# Dense semantic search
models.Prefetch(
query=dense_embedding,
using="dense",
limit=limit * 2, # Get extra for deduplication
filter=query_filter,
),
# Sparse BM25 search
models.Prefetch(
query=models.SparseVector(
indices=sparse_embedding["indices"],
values=sparse_embedding["values"],
),
# Sparse BM25 search
models.Prefetch(
query=models.SparseVector(
indices=sparse_embedding["indices"],
values=sparse_embedding["values"],
),
using="sparse",
limit=limit * 2, # Get extra for deduplication
filter=query_filter,
),
],
# Fusion query (RRF or DBSF based on initialization)
query=models.FusionQuery(fusion=self.fusion),
limit=limit * 2, # Get extra for deduplication
score_threshold=score_threshold,
with_payload=True,
with_vectors=False, # Don't return vectors to save bandwidth
)
using="sparse",
limit=limit * 2, # Get extra for deduplication
filter=query_filter,
),
],
# Fusion query (RRF or DBSF based on initialization)
query=models.FusionQuery(fusion=self.fusion),
limit=limit * 2, # Get extra for deduplication
score_threshold=score_threshold,
with_payload=True,
with_vectors=False, # Don't return vectors to save bandwidth
)
record_qdrant_operation("search", "success")
except Exception:
record_qdrant_operation("search", "error")
@@ -196,61 +185,47 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
# Deduplicate by (doc_id, doc_type, chunk_start, chunk_end)
# This allows multiple chunks from same doc, but removes duplicate chunks
with trace_operation(
"search.deduplicate",
attributes={"dedupe.num_points": len(search_response.points)},
):
seen_chunks = set()
results = []
seen_chunks = set()
results = []
for result in search_response.points:
if result.payload is None:
continue
# doc_id can be int (notes) or str (files - file paths)
doc_id = result.payload["doc_id"]
doc_type = result.payload.get("doc_type", "note")
chunk_start = result.payload.get("chunk_start_offset")
chunk_end = result.payload.get("chunk_end_offset")
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
for result in search_response.points:
# doc_id can be int (notes) or str (files - file paths)
doc_id = result.payload["doc_id"]
doc_type = result.payload.get("doc_type", "note")
chunk_start = result.payload.get("chunk_start_offset")
chunk_end = result.payload.get("chunk_end_offset")
chunk_key = (doc_id, doc_type, chunk_start, chunk_end)
# Skip if we've already seen this exact chunk
if chunk_key in seen_chunks:
continue
# Skip if we've already seen this exact chunk
if chunk_key in seen_chunks:
continue
seen_chunks.add(chunk_key)
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
}
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
id=doc_id,
doc_type=doc_type,
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score, # Fusion score (RRF or DBSF)
metadata=metadata,
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
chunk_index=result.payload.get("chunk_index", 0),
total_chunks=result.payload.get("total_chunks", 1),
point_id=str(result.id), # Qdrant point ID for batch retrieval
)
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
id=doc_id,
doc_type=doc_type,
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score, # Fusion score (RRF or DBSF)
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
"search_method": f"bm25_hybrid_{self.fusion_name}",
},
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
chunk_index=result.payload.get("chunk_index", 0),
total_chunks=result.payload.get("total_chunks", 1),
point_id=str(result.id), # Qdrant point ID for batch retrieval
)
)
if len(results) >= limit:
break
if len(results) >= limit:
break
logger.info(f"Returning {len(results)} unverified results after deduplication")
if results:
+2 -149
View File
@@ -209,64 +209,6 @@ async def _get_file_path_from_qdrant(
return None
async def _get_deck_metadata_from_qdrant(
user_id: str, card_id: int
) -> dict[str, int] | None:
"""Retrieve board_id and stack_id for a deck card from Qdrant payload.
Args:
user_id: User ID who owns the card
card_id: Card ID
Returns:
Dictionary with board_id and stack_id, or None if not found
"""
try:
from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
qdrant_client = await get_qdrant_client()
settings = get_settings()
# Query for any chunk of this card (we just need metadata)
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_id", match=MatchValue(value=card_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
limit=1,
with_payload=["board_id", "stack_id"],
with_vectors=False,
)
if scroll_result[0]:
point = scroll_result[0][0]
board_id = point.payload.get("board_id")
stack_id = point.payload.get("stack_id")
if board_id is not None and stack_id is not None:
logger.debug(
f"Retrieved deck metadata for card {card_id}: "
f"board_id={board_id}, stack_id={stack_id}"
)
return {"board_id": int(board_id), "stack_id": int(stack_id)}
logger.debug(
f"Could not find deck metadata in Qdrant for card {card_id} "
f"(might be legacy data without board_id/stack_id)"
)
return None
except Exception as e:
logger.debug(f"Error querying Qdrant for deck metadata: {e}")
return None
@dataclass
class ChunkContext:
"""Expanded chunk with surrounding context and position markers.
@@ -452,9 +394,7 @@ async def get_chunk_with_context(
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
# Fetch full document text
full_text = await _fetch_document_text(
nc_client, resolved_doc_id, doc_type, user_id
)
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
if full_text is None:
logger.warning(
f"Could not fetch document text for {doc_type} {doc_id}, "
@@ -513,7 +453,7 @@ async def get_chunk_with_context(
async def _fetch_document_text(
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
) -> str | None:
"""Fetch full text content of a document.
@@ -584,93 +524,6 @@ async def _fetch_document_text(
f"Error fetching file content for {doc_id}: {e}", exc_info=True
)
return None
elif doc_type == "news_item":
# Fetch news item by ID
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_id))
# Reconstruct full content as indexed: title + source + URL + body
# This ensures chunk offsets align with indexed content structure
body_markdown = html_to_markdown(item.get("body", ""))
item_title = item.get("title", "")
item_url = item.get("url", "")
feed_title = item.get("feedTitle", "")
content_parts = [item_title]
if feed_title:
content_parts.append(f"Source: {feed_title}")
if item_url:
content_parts.append(f"URL: {item_url}")
content_parts.append("") # Blank line
content_parts.append(body_markdown)
return "\n".join(content_parts)
elif doc_type == "deck_card":
# Fetch card from Deck API
# Try to get board_id/stack_id from Qdrant metadata (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
deck_metadata = await _get_deck_metadata_from_qdrant(user_id, int(doc_id))
if deck_metadata:
# Fast path: Direct lookup with known board_id/stack_id
board_id = deck_metadata["board_id"]
stack_id = deck_metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=board_id, stack_id=stack_id, card_id=int(doc_id)
)
logger.debug(
f"Retrieved deck card {doc_id} using metadata "
f"(board_id={board_id}, stack_id={stack_id})"
)
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, "
f"stack_id={stack_id}, card_id={doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for board in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(
f"Skipping deleted board {board.id} while searching for card {doc_id}"
)
continue
stacks = await nc_client.deck.get_stacks(board.id)
for stack in stacks:
if card_found:
break
if stack.cards:
for c in stack.cards:
if c.id == int(doc_id):
card = c
card_found = True
logger.debug(
f"Found deck card {doc_id} in board {board.id}, "
f"stack {stack.id} (fallback iteration)"
)
break
if not card_found:
logger.warning(f"Deck card {doc_id} not found in any board/stack")
return None
# Reconstruct full content as indexed: title + "\n\n" + description
# This ensures chunk offsets align with indexed content structure
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
return "\n\n".join(content_parts)
else:
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
return None
+4 -14
View File
@@ -136,8 +136,6 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
results = []
for result in search_response.points:
if result.payload is None:
continue
# doc_id can be int (notes) or str (files - file paths)
doc_id = result.payload["doc_id"]
doc_type = result.payload.get("doc_type", "note")
@@ -151,17 +149,6 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
seen_chunks.add(chunk_key)
# Build metadata dict with common fields
metadata = {
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
}
# Add deck_card-specific metadata for frontend URL construction
if doc_type == "deck_card":
if board_id := result.payload.get("board_id"):
metadata["board_id"] = board_id
# Return unverified results (verification happens at output stage)
results.append(
SearchResult(
@@ -170,7 +157,10 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
title=result.payload.get("title", "Untitled"),
excerpt=result.payload.get("excerpt", ""),
score=result.score,
metadata=metadata,
metadata={
"chunk_index": result.payload.get("chunk_index"),
"total_chunks": result.payload.get("total_chunks"),
},
chunk_start_offset=result.payload.get("chunk_start_offset"),
chunk_end_offset=result.payload.get("chunk_end_offset"),
page_number=result.payload.get("page_number"),
-2
View File
@@ -2,7 +2,6 @@ from .calendar import configure_calendar_tools
from .contacts import configure_contacts_tools
from .cookbook import configure_cookbook_tools
from .deck import configure_deck_tools
from .news import configure_news_tools
from .notes import configure_notes_tools
from .semantic import configure_semantic_tools
from .sharing import configure_sharing_tools
@@ -14,7 +13,6 @@ __all__ = [
"configure_contacts_tools",
"configure_cookbook_tools",
"configure_deck_tools",
"configure_news_tools",
"configure_notes_tools",
"configure_semantic_tools",
"configure_sharing_tools",
+16 -69
View File
@@ -3,7 +3,6 @@ import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -20,10 +19,7 @@ logger = logging.getLogger(__name__)
def configure_calendar_tools(mcp: FastMCP):
# Calendar tools
@mcp.tool(
title="List Calendars",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
@@ -34,10 +30,7 @@ def configure_calendar_tools(mcp: FastMCP):
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
@mcp.tool(
title="Create Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_create_event(
@@ -114,10 +107,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool(
title="List Calendar Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_list_events(
@@ -220,10 +210,7 @@ def configure_calendar_tools(mcp: FastMCP):
return events
@mcp.tool(
title="Get Calendar Event",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_get_event(
@@ -236,10 +223,7 @@ def configure_calendar_tools(mcp: FastMCP):
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
return event_data
@mcp.tool(
title="Update Calendar Event",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_update_event(
@@ -313,12 +297,7 @@ def configure_calendar_tools(mcp: FastMCP):
calendar_name, event_uid, event_data, etag
)
@mcp.tool(
title="Delete Calendar Event",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_delete_event(
@@ -330,10 +309,7 @@ def configure_calendar_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.calendar.delete_event(calendar_name, event_uid)
@mcp.tool(
title="Create Meeting",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_create_meeting(
@@ -400,10 +376,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_event(calendar_name, event_data)
@mcp.tool(
title="Get Upcoming Events",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_get_upcoming_events(
@@ -454,10 +427,7 @@ def configure_calendar_tools(mcp: FastMCP):
all_events.sort(key=lambda x: x.get("start_datetime", ""))
return all_events[:limit]
@mcp.tool(
title="Find Availability",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:read")
@instrument_tool
async def nc_calendar_find_availability(
@@ -538,10 +508,7 @@ def configure_calendar_tools(mcp: FastMCP):
constraints=constraints,
)
@mcp.tool(
title="Bulk Calendar Operations",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_bulk_operations(
@@ -791,10 +758,7 @@ def configure_calendar_tools(mcp: FastMCP):
"results": results,
}
@mcp.tool(
title="Manage Calendar",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("calendar:write")
@instrument_tool
async def nc_calendar_manage_calendar(
@@ -864,10 +828,7 @@ def configure_calendar_tools(mcp: FastMCP):
# ============= Todo/Task Tools =============
@mcp.tool(
title="List Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("todo:read", "calendar:read")
@instrument_tool
async def nc_calendar_list_todos(
@@ -913,10 +874,7 @@ def configure_calendar_tools(mcp: FastMCP):
todos=todos, calendar_name=calendar_name, total_count=len(todos)
)
@mcp.tool(
title="Create Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_create_todo(
@@ -960,10 +918,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.create_todo(calendar_name, todo_data)
@mcp.tool(
title="Update Todo Task",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_update_todo(
@@ -1024,12 +979,7 @@ def configure_calendar_tools(mcp: FastMCP):
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
@mcp.tool(
title="Delete Todo Task",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("todo:write", "calendar:read")
@instrument_tool
async def nc_calendar_delete_todo(
@@ -1050,10 +1000,7 @@ def configure_calendar_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.calendar.delete_todo(calendar_name, todo_uid)
@mcp.tool(
title="Search Todo Tasks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("todo:read", "calendar:read")
@instrument_tool
async def nc_calendar_search_todos(
+7 -33
View File
@@ -1,7 +1,6 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -12,10 +11,7 @@ logger = logging.getLogger(__name__)
def configure_contacts_tools(mcp: FastMCP):
# Contacts tools
@mcp.tool(
title="List Address Books",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_addressbooks(ctx: Context):
@@ -23,10 +19,7 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.list_addressbooks()
@mcp.tool(
title="List Contacts",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("contacts:read")
@instrument_tool
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
@@ -34,10 +27,7 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.list_contacts(addressbook=addressbook)
@mcp.tool(
title="Create Address Book",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_create_addressbook(
@@ -54,12 +44,7 @@ def configure_contacts_tools(mcp: FastMCP):
name=name, display_name=display_name
)
@mcp.tool(
title="Delete Address Book",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
@@ -67,10 +52,7 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.delete_addressbook(name=name)
@mcp.tool(
title="Create Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_create_contact(
@@ -88,12 +70,7 @@ def configure_contacts_tools(mcp: FastMCP):
addressbook=addressbook, uid=uid, contact_data=contact_data
)
@mcp.tool(
title="Delete Contact",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
@@ -101,10 +78,7 @@ def configure_contacts_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
@mcp.tool(
title="Update Contact",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("contacts:write")
@instrument_tool
async def nc_contacts_update_contact(
+14 -55
View File
@@ -3,7 +3,7 @@ import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -71,10 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Import Recipe from URL",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
@@ -132,10 +129,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="List Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
@@ -161,10 +155,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Get Recipe",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
@@ -190,10 +181,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Create Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_create_recipe(
@@ -273,10 +261,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Update Recipe",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_update_recipe(
@@ -366,12 +351,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Delete Recipe",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_delete_recipe(
@@ -407,10 +387,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Search Recipes",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_search_recipes(
@@ -447,10 +424,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="List Recipe Categories",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
@@ -478,10 +452,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Get Recipes in Category",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipes_in_category(
@@ -518,10 +489,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="List Recipe Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
@@ -547,10 +515,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Get Recipes with Keywords",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:read")
@instrument_tool
async def nc_cookbook_get_recipes_with_keywords(
@@ -585,10 +550,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Set Cookbook Configuration",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_set_config(
@@ -632,10 +594,7 @@ def configure_cookbook_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Reindex Recipes",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("cookbook:write")
@instrument_tool
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
+25 -107
View File
@@ -2,7 +2,6 @@ import logging
from typing import Optional
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -118,10 +117,7 @@ def configure_deck_tools(mcp: FastMCP):
# Read Tools (converted from resources)
@mcp.tool(
title="List Deck Boards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
@@ -130,10 +126,7 @@ def configure_deck_tools(mcp: FastMCP):
boards = await client.deck.get_boards()
return boards
@mcp.tool(
title="Get Deck Board",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
@@ -142,10 +135,7 @@ def configure_deck_tools(mcp: FastMCP):
board = await client.deck.get_board(board_id)
return board
@mcp.tool(
title="List Deck Stacks",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
@@ -154,10 +144,7 @@ def configure_deck_tools(mcp: FastMCP):
stacks = await client.deck.get_stacks(board_id)
return stacks
@mcp.tool(
title="Get Deck Stack",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
@@ -166,10 +153,7 @@ def configure_deck_tools(mcp: FastMCP):
stack = await client.deck.get_stack(board_id, stack_id)
return stack
@mcp.tool(
title="List Deck Cards",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_cards(
@@ -182,10 +166,7 @@ def configure_deck_tools(mcp: FastMCP):
return stack.cards
return []
@mcp.tool(
title="Get Deck Card",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_card(
@@ -196,10 +177,7 @@ def configure_deck_tools(mcp: FastMCP):
card = await client.deck.get_card(board_id, stack_id, card_id)
return card
@mcp.tool(
title="List Deck Labels",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
@@ -208,10 +186,7 @@ def configure_deck_tools(mcp: FastMCP):
board = await client.deck.get_board(board_id)
return board.labels
@mcp.tool(
title="Get Deck Label",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:read")
@instrument_tool
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
@@ -222,10 +197,7 @@ def configure_deck_tools(mcp: FastMCP):
# Create/Update/Delete Tools
@mcp.tool(
title="Create Deck Board",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_create_board(
@@ -243,10 +215,7 @@ def configure_deck_tools(mcp: FastMCP):
# Stack Tools
@mcp.tool(
title="Create Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_create_stack(
@@ -263,10 +232,7 @@ def configure_deck_tools(mcp: FastMCP):
stack = await client.deck.create_stack(board_id, title, order)
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
@mcp.tool(
title="Update Deck Stack",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_update_stack(
@@ -293,12 +259,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Delete Deck Stack",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_stack(
@@ -320,10 +281,7 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card Tools
@mcp.tool(
title="Create Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_create_card(
@@ -358,10 +316,7 @@ def configure_deck_tools(mcp: FastMCP):
stackId=card.stackId,
)
@mcp.tool(
title="Update Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_update_card(
@@ -415,12 +370,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Delete Deck Card",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_card(
@@ -443,10 +393,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Archive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_archive_card(
@@ -469,10 +416,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Unarchive Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_unarchive_card(
@@ -495,10 +439,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Reorder/Move Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_reorder_card(
@@ -531,10 +472,7 @@ def configure_deck_tools(mcp: FastMCP):
)
# Label Tools
@mcp.tool(
title="Create Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_create_label(
@@ -551,10 +489,7 @@ def configure_deck_tools(mcp: FastMCP):
label = await client.deck.create_label(board_id, title, color)
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
@mcp.tool(
title="Update Deck Label",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_update_label(
@@ -581,12 +516,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Delete Deck Label",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_delete_label(
@@ -608,10 +538,7 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card-Label Assignment Tools
@mcp.tool(
title="Assign Label to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_assign_label_to_card(
@@ -635,10 +562,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Remove Label from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_remove_label_from_card(
@@ -663,10 +587,7 @@ def configure_deck_tools(mcp: FastMCP):
)
# Card-User Assignment Tools
@mcp.tool(
title="Assign User to Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_assign_user_to_card(
@@ -690,10 +611,7 @@ def configure_deck_tools(mcp: FastMCP):
board_id=board_id,
)
@mcp.tool(
title="Unassign User from Deck Card",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("deck:write")
@instrument_tool
async def deck_unassign_user_from_card(
-384
View File
@@ -1,384 +0,0 @@
"""MCP tools for Nextcloud News app."""
import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.client.news import NewsItemType
from nextcloud_mcp_server.context import get_client
from nextcloud_mcp_server.models.news import (
FeedHealthResponse,
GetItemResponse,
GetStatusResponse,
ListFeedsResponse,
ListFoldersResponse,
ListItemsResponse,
NewsFeed,
NewsFolder,
NewsItem,
NewsItemSummary,
)
from nextcloud_mcp_server.observability.metrics import instrument_tool
logger = logging.getLogger(__name__)
def configure_news_tools(mcp: FastMCP):
"""Configure News app MCP tools."""
@mcp.tool(
title="List News Folders",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_folders(ctx: Context) -> ListFoldersResponse:
"""List all News folders (requires news:read scope)."""
client = await get_client(ctx)
try:
folders_data = await client.news.get_folders()
folders = [NewsFolder(**f) for f in folders_data]
return ListFoldersResponse(results=folders, total_count=len(folders))
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing folders: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list folders: {e.response.status_code}",
)
)
@mcp.tool(
title="List News Feeds",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_feeds(ctx: Context) -> ListFeedsResponse:
"""List all News feeds with metadata (requires news:read scope).
Returns feeds with unread counts, error status, and overall starred count.
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
feeds = [NewsFeed(**f) for f in data.get("feeds", [])]
return ListFeedsResponse(
results=feeds,
starred_count=data.get("starredCount", 0),
newest_item_id=data.get("newestItemId"),
total_count=len(feeds),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing feeds: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list feeds: {e.response.status_code}",
)
)
@mcp.tool(
title="List News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_list_items(
ctx: Context,
feed_id: int | None = None,
folder_id: int | None = None,
starred_only: bool = False,
unread_only: bool = False,
limit: int = 50,
offset: int = 0,
) -> ListItemsResponse:
"""List News items (articles) with optional filtering (requires news:read scope).
Args:
feed_id: Filter by specific feed ID
folder_id: Filter by specific folder ID
starred_only: Return only starred items
unread_only: Return only unread items
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with items, count, and pagination info
"""
client = await get_client(ctx)
# Determine item type filter
type_ = NewsItemType.ALL
id_ = 0
if starred_only:
type_ = NewsItemType.STARRED
elif feed_id is not None:
type_ = NewsItemType.FEED
id_ = feed_id
elif folder_id is not None:
type_ = NewsItemType.FOLDER
id_ = folder_id
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=type_,
id_=id_,
get_read=not unread_only,
)
items = [NewsItemSummary(**i) for i in items_data]
# Determine pagination info
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error listing items: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to list items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News Item",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_item(item_id: int, ctx: Context) -> GetItemResponse:
"""Get a specific News item by ID with full content (requires news:read scope).
Args:
item_id: Item ID
Returns:
GetItemResponse with full item details including HTML body
"""
client = await get_client(ctx)
try:
item_data = await client.news.get_item(item_id)
item = NewsItem(**item_data)
return GetItemResponse(item=item)
except ValueError as e:
raise McpError(ErrorData(code=-1, message=str(e)))
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting item {item_id}: {str(e)}"
)
)
except HTTPStatusError as e:
if e.response.status_code == 404:
raise McpError(ErrorData(code=-1, message=f"Item {item_id} not found"))
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get item {item_id}: {e.response.status_code}",
)
)
@mcp.tool(
title="Get Starred News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_starred_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get starred (favorited) News items (requires news:read scope).
Convenience method for retrieving user's starred articles.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with starred items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.STARRED,
get_read=True, # Include read starred items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting starred items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get starred items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get Unread News Items",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_unread_items(
ctx: Context, limit: int = 50, offset: int = 0
) -> ListItemsResponse:
"""Get unread News items (requires news:read scope).
Convenience method for retrieving unread articles across all feeds.
Args:
limit: Maximum number of items to return (default 50, -1 for all)
offset: Item ID to start after (for pagination)
Returns:
ListItemsResponse with unread items
"""
client = await get_client(ctx)
try:
items_data = await client.news.get_items(
batch_size=limit,
offset=offset,
type_=NewsItemType.ALL,
get_read=False, # Only unread items
)
items = [NewsItemSummary(**i) for i in items_data]
oldest_id = min((i.id for i in items), default=None) if items else None
has_more = len(items) == limit and limit > 0
return ListItemsResponse(
results=items,
total_count=len(items),
has_more=has_more,
oldest_id=oldest_id,
)
except RequestError as e:
raise McpError(
ErrorData(
code=-1, message=f"Network error getting unread items: {str(e)}"
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get unread items: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News Feed Health",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_feed_health(feed_id: int, ctx: Context) -> FeedHealthResponse:
"""Get health status for a specific feed (requires news:read scope).
Returns error count and last error message if the feed has update issues.
Args:
feed_id: Feed ID to check
Returns:
FeedHealthResponse with error status
"""
client = await get_client(ctx)
try:
data = await client.news.get_feeds()
for feed_data in data.get("feeds", []):
if feed_data.get("id") == feed_id:
feed = NewsFeed(**feed_data)
return FeedHealthResponse(
feed_id=feed.id,
title=feed.title,
url=feed.url,
has_errors=feed.has_errors,
error_count=feed.update_error_count,
last_error=feed.last_update_error,
)
raise McpError(ErrorData(code=-1, message=f"Feed {feed_id} not found"))
except RequestError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Network error getting feed health: {str(e)}",
)
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get feed health: {e.response.status_code}",
)
)
@mcp.tool(
title="Get News App Status",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@require_scopes("news:read")
@instrument_tool
async def nc_news_get_status(ctx: Context) -> GetStatusResponse:
"""Get News app status and version (requires news:read scope).
Returns version information and any configuration warnings.
"""
client = await get_client(ctx)
try:
status_data = await client.news.get_status()
return GetStatusResponse(
version=status_data.get("version", "unknown"),
warnings=status_data.get("warnings", {}),
)
except RequestError as e:
raise McpError(
ErrorData(code=-1, message=f"Network error getting status: {str(e)}")
)
except HTTPStatusError as e:
raise McpError(
ErrorData(
code=-1,
message=f"Failed to get status: {e.response.status_code}",
)
)
+8 -51
View File
@@ -3,7 +3,7 @@ import logging
from httpx import HTTPStatusError, RequestError
from mcp.server.fastmcp import Context, FastMCP
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData, ToolAnnotations
from mcp.types import ErrorData
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -85,13 +85,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Create Note",
annotations=ToolAnnotations(
idempotentHint=False, # Multiple calls create multiple notes
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_create_note(
@@ -138,13 +132,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Update Note",
annotations=ToolAnnotations(
idempotentHint=False, # Requires etag which changes = not idempotent
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_update_note(
@@ -210,13 +198,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Append to Note",
annotations=ToolAnnotations(
idempotentHint=False, # Each call adds content = not idempotent
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_append_content(
@@ -267,13 +249,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Search Notes",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
@@ -320,13 +296,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Get Note",
annotations=ToolAnnotations(
readOnlyHint=True, # Read operation only
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
@@ -356,13 +326,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Get Note Attachment",
annotations=ToolAnnotations(
readOnlyHint=True, # Read operation only
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:read")
@instrument_tool
async def nc_notes_get_attachment(
@@ -409,14 +373,7 @@ def configure_notes_tools(mcp: FastMCP):
)
)
@mcp.tool(
title="Delete Note",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Deleting deleted note = same end state
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("notes:write")
@instrument_tool
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
@@ -15,7 +15,6 @@ import httpx
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
from nextcloud_mcp_server.auth import require_scopes
@@ -685,16 +684,11 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="provision_nextcloud_access",
title="Grant Server Access to Nextcloud",
description=(
"Provision offline access to Nextcloud resources. "
"This is required before using Nextcloud tools. "
"You'll need to complete an OAuth authorization in your browser."
),
annotations=ToolAnnotations(
idempotentHint=False, # Creates new OAuth session each time
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_provision_access(
@@ -705,13 +699,7 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="revoke_nextcloud_access",
title="Revoke Server Access to Nextcloud",
description="Revoke offline access to Nextcloud resources.",
annotations=ToolAnnotations(
destructiveHint=True, # Removes stored access tokens
idempotentHint=True, # Revoking revoked access = same end state
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_revoke_access(
@@ -721,12 +709,7 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="check_provisioning_status",
title="Check Provisioning Status",
description="Check whether Nextcloud access is provisioned.",
annotations=ToolAnnotations(
readOnlyHint=True, # Only checks status, doesn't modify
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_check_status(
@@ -736,15 +719,10 @@ def register_oauth_tools(mcp):
@mcp.tool(
name="check_logged_in",
title="Check Server Login Status",
description=(
"Check if you are logged in to Nextcloud. "
"If not logged in, this tool will prompt you to complete the login flow."
),
annotations=ToolAnnotations(
readOnlyHint=True, # Checking status doesn't modify state
openWorldHint=True,
),
)
@require_scopes("openid")
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
+11 -39
View File
@@ -12,7 +12,6 @@ from mcp.types import (
ModelPreferences,
SamplingMessage,
TextContent,
ToolAnnotations,
)
from nextcloud_mcp_server.auth import require_scopes
@@ -35,13 +34,7 @@ logger = logging.getLogger(__name__)
def configure_semantic_tools(mcp: FastMCP):
"""Configure semantic search tools for MCP server."""
@mcp.tool(
title="Semantic Search",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=True, # Queries external Nextcloud service
),
)
@mcp.tool()
@require_scopes("semantic:read")
@instrument_tool
async def nc_semantic_search(
@@ -65,13 +58,13 @@ def configure_semantic_tools(mcp: FastMCP):
database for optimal relevance. This provides the best of both semantic
understanding and keyword precision.
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
news items, and deck cards.
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
fully supported for indexing.
Args:
query: Natural language or keyword search query
limit: Maximum number of results to return (default: 10)
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
score_threshold: Minimum fusion score (0-1, default: 0.0)
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
RRF: Good general-purpose fusion using reciprocal ranks
@@ -292,13 +285,7 @@ def configure_semantic_tools(mcp: FastMCP):
logger.error(f"Search error: {e}", exc_info=True)
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
@mcp.tool(
title="Search with AI-Generated Answer",
annotations=ToolAnnotations(
readOnlyHint=True, # Search doesn't modify data
openWorldHint=False, # Searches only indexed Nextcloud data
),
)
@mcp.tool()
@require_scopes("semantic:read")
@instrument_tool
async def nc_semantic_search_answer(
@@ -512,11 +499,9 @@ def configure_semantic_tools(mcp: FastMCP):
)
# 6. Request LLM completion via MCP sampling with timeout
# Note: 5 minute timeout to accommodate slower local LLMs (e.g., Ollama)
sampling_timeout_seconds = 300
try:
with anyio.fail_after(sampling_timeout_seconds):
with anyio.fail_after(30):
sampling_result = await ctx.session.create_message(
messages=[
SamplingMessage(
@@ -563,14 +548,14 @@ def configure_semantic_tools(mcp: FastMCP):
except TimeoutError:
logger.warning(
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
f"Sampling request timed out after 30 seconds for query: '{query}', "
f"returning search results only"
)
return SamplingSearchResponse(
query=query,
generated_answer=(
f"[Sampling request timed out]\n\n"
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
f"The answer generation took too long (>30s). "
f"Found {len(accessible_results)} relevant documents. "
f"Please review the sources below or try a simpler query."
),
@@ -636,13 +621,7 @@ def configure_semantic_tools(mcp: FastMCP):
success=True,
)
@mcp.tool(
title="Check Indexing Status",
annotations=ToolAnnotations(
readOnlyHint=True, # Only checks status
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("semantic:read")
@instrument_tool
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
@@ -696,22 +675,15 @@ def configure_semantic_tools(mcp: FastMCP):
# Get Qdrant client and query indexed count
indexed_count = 0
try:
from qdrant_client.models import Filter
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.vector.placeholder import (
get_placeholder_filter,
)
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
settings = get_settings()
qdrant_client = await get_qdrant_client()
# Count documents in collection, excluding placeholders
# Placeholders are zero-vector points used to track processing state
# Count documents in collection
count_result = await qdrant_client.count(
collection_name=settings.get_collection_name(),
count_filter=Filter(must=[get_placeholder_filter()]),
collection_name=settings.get_collection_name()
)
indexed_count = count_result.count
+5 -23
View File
@@ -3,7 +3,6 @@
import json
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -17,10 +16,7 @@ def configure_sharing_tools(mcp: FastMCP):
mcp: FastMCP server instance
"""
@mcp.tool(
title="Create Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_create(
@@ -60,12 +56,7 @@ def configure_sharing_tools(mcp: FastMCP):
)
return json.dumps(share_data, indent=2)
@mcp.tool(
title="Delete Share",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_delete(share_id: int, ctx: Context) -> str:
@@ -85,10 +76,7 @@ def configure_sharing_tools(mcp: FastMCP):
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
)
@mcp.tool(
title="Get Share Details",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_get(share_id: int, ctx: Context) -> str:
@@ -107,10 +95,7 @@ def configure_sharing_tools(mcp: FastMCP):
share_data = await client.sharing.get_share(share_id)
return json.dumps(share_data, indent=2)
@mcp.tool(
title="List Shares",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_list(
@@ -132,10 +117,7 @@ def configure_sharing_tools(mcp: FastMCP):
)
return json.dumps(shares, indent=2)
@mcp.tool(
title="Update Share",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("sharing:write")
@instrument_tool
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
+6 -27
View File
@@ -1,7 +1,6 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -12,10 +11,7 @@ logger = logging.getLogger(__name__)
def configure_tables_tools(mcp: FastMCP):
# Tables tools
@mcp.tool(
title="List Tables",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_list_tables(ctx: Context):
@@ -23,10 +19,7 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.list_tables()
@mcp.tool(
title="Get Table Schema",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_get_schema(table_id: int, ctx: Context):
@@ -34,10 +27,7 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.get_table_schema(table_id)
@mcp.tool(
title="Read Table Rows",
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
)
@mcp.tool()
@require_scopes("tables:read")
@instrument_tool
async def nc_tables_read_table(
@@ -50,10 +40,7 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.get_table_rows(table_id, limit, offset)
@mcp.tool(
title="Insert Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
@@ -64,10 +51,7 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.create_row(table_id, data)
@mcp.tool(
title="Update Table Row",
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
)
@mcp.tool()
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
@@ -78,12 +62,7 @@ def configure_tables_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.tables.update_row(row_id, data)
@mcp.tool(
title="Delete Table Row",
annotations=ToolAnnotations(
destructiveHint=True, idempotentHint=True, openWorldHint=True
),
)
@mcp.tool()
@require_scopes("tables:write")
@instrument_tool
async def nc_tables_delete_row(row_id: int, ctx: Context):
+11 -79
View File
@@ -1,7 +1,6 @@
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.context import get_client
@@ -17,13 +16,7 @@ logger = logging.getLogger(__name__)
def configure_webdav_tools(mcp: FastMCP):
# WebDAV file system tools
@mcp.tool(
title="List Files and Directories",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_list_directory(
@@ -57,13 +50,7 @@ def configure_webdav_tools(mcp: FastMCP):
total_size=total_size,
)
@mcp.tool(
title="Read File",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_read_file(path: str, ctx: Context):
@@ -130,13 +117,7 @@ def configure_webdav_tools(mcp: FastMCP):
"encoding": "base64",
}
@mcp.tool(
title="Write File",
annotations=ToolAnnotations(
idempotentHint=True, # HTTP PUT without version control is idempotent
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_write_file(
@@ -165,13 +146,7 @@ def configure_webdav_tools(mcp: FastMCP):
return await client.webdav.write_file(path, content_bytes, content_type)
@mcp.tool(
title="Create Directory",
annotations=ToolAnnotations(
idempotentHint=True, # Creating existing dir returns 405 = same end state
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_create_directory(path: str, ctx: Context):
@@ -186,14 +161,7 @@ def configure_webdav_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.webdav.create_directory(path)
@mcp.tool(
title="Delete File or Directory",
annotations=ToolAnnotations(
destructiveHint=True, # Permanently deletes data
idempotentHint=True, # Deleting deleted resource = same end state
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_delete_resource(path: str, ctx: Context):
@@ -208,13 +176,7 @@ def configure_webdav_tools(mcp: FastMCP):
client = await get_client(ctx)
return await client.webdav.delete_resource(path)
@mcp.tool(
title="Move or Rename File",
annotations=ToolAnnotations(
idempotentHint=False, # Moving changes source and dest
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_move_resource(
@@ -235,13 +197,7 @@ def configure_webdav_tools(mcp: FastMCP):
source_path, destination_path, overwrite
)
@mcp.tool(
title="Copy File or Directory",
annotations=ToolAnnotations(
idempotentHint=False, # Creates new resource each time
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:write")
@instrument_tool
async def nc_webdav_copy_resource(
@@ -262,13 +218,7 @@ def configure_webdav_tools(mcp: FastMCP):
source_path, destination_path, overwrite
)
@mcp.tool(
title="Search Files",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_search_files(
@@ -385,13 +335,7 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied=filters if filters else None,
)
@mcp.tool(
title="Find Files by Name",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_find_by_name(
@@ -419,13 +363,7 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied={"name_pattern": pattern},
)
@mcp.tool(
title="Find Files by Type",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_find_by_type(
@@ -453,13 +391,7 @@ def configure_webdav_tools(mcp: FastMCP):
filters_applied={"mime_type": mime_type},
)
@mcp.tool(
title="List Favorite Files",
annotations=ToolAnnotations(
readOnlyHint=True,
openWorldHint=True,
),
)
@mcp.tool()
@require_scopes("files:read")
@instrument_tool
async def nc_webdav_list_favorites(
@@ -1,49 +0,0 @@
"""HTML to Markdown conversion utilities for vector sync."""
import logging
from markdownify import markdownify as md
logger = logging.getLogger(__name__)
def html_to_markdown(html_content: str | None) -> str:
"""Convert HTML content to Markdown, preserving semantic structure.
This function converts HTML (typically from RSS/Atom feed items) to Markdown
for better text embedding. Markdown preserves:
- Heading hierarchy (important for document structure)
- Lists (bullet and numbered)
- Links (as [text](url))
- Bold/italic emphasis
- Paragraphs and line breaks
Args:
html_content: HTML string to convert (may be None or empty)
Returns:
Markdown string, or empty string if input is None/empty
Example:
>>> html_to_markdown("<h1>Title</h1><p>Content with <b>bold</b>.</p>")
'# Title\\n\\nContent with **bold**.\\n\\n'
"""
if not html_content:
return ""
try:
markdown = md(
html_content,
heading_style="ATX", # Use # style headings
strip=["script", "style", "iframe", "noscript"], # Remove unsafe elements
bullets="-", # Use - for unordered lists
code_language="", # Don't add language hints to code blocks
)
return markdown.strip()
except Exception as e:
logger.warning(f"Failed to convert HTML to Markdown: {e}")
# Fallback: strip all HTML tags as a last resort
import re
text = re.sub(r"<[^>]+>", " ", html_content)
return " ".join(text.split()) # Normalize whitespace
+6 -174
View File
@@ -6,7 +6,6 @@ Processes documents from stream: fetches content, generates embeddings, stores i
import logging
import time
import uuid
from typing import Any, cast
import anyio
from anyio.abc import TaskStatus
@@ -273,136 +272,6 @@ async def _index_document(
file_path = None # Notes don't have file paths
content_bytes = None # Notes don't have binary content
content_type = None
elif doc_task.doc_type == "news_item":
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
item = await nc_client.news.get_item(int(doc_task.doc_id))
# Convert HTML body to Markdown for better embedding
body_markdown = html_to_markdown(item.get("body", ""))
# Build content: title + URL + body
item_title = item.get("title", "")
item_url = item.get("url", "")
feed_title = item.get("feedTitle", "")
# Structure content for embedding
content_parts = [item_title]
if feed_title:
content_parts.append(f"Source: {feed_title}")
if item_url:
content_parts.append(f"URL: {item_url}")
content_parts.append("") # Blank line
content_parts.append(body_markdown)
content = "\n".join(content_parts)
title = item_title
etag = item.get("guidHash", "")
# Store news-specific metadata for later use in payload
file_metadata = {
"feed_id": item.get("feedId"),
"feed_title": feed_title,
"author": item.get("author"),
"pub_date": item.get("pubDate"),
"starred": item.get("starred", False),
"unread": item.get("unread", True),
"url": item_url,
"guid_hash": item.get("guidHash"),
"enclosure_link": item.get("enclosureLink"),
"enclosure_mime": item.get("enclosureMime"),
}
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "deck_card":
# Fetch card from Deck API
# Use metadata from scanner if available (O(1) lookup)
# Otherwise fall back to iteration (legacy data)
card = None
board = None
stack = None
if (
doc_task.metadata
and "board_id" in doc_task.metadata
and "stack_id" in doc_task.metadata
):
# Fast path: Direct lookup with known board_id/stack_id
board_id = doc_task.metadata["board_id"]
stack_id = doc_task.metadata["stack_id"]
try:
card = await nc_client.deck.get_card(
board_id=int(board_id),
stack_id=int(stack_id),
card_id=int(doc_task.doc_id),
)
# Fetch board and stack info for metadata
boards = await nc_client.deck.get_boards()
for b in boards:
if b.id == int(board_id):
board = b
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if s.id == int(stack_id):
stack = s
break
break
except Exception as e:
logger.warning(
f"Failed to fetch card with metadata (board_id={board_id}, stack_id={stack_id}, card_id={doc_task.doc_id}): {e}, falling back to iteration"
)
# Fallback: Iterate through all boards/stacks (for legacy data or if fast path failed)
if card is None:
boards = await nc_client.deck.get_boards()
card_found = False
for b in boards:
if card_found:
break
# Skip deleted boards (soft delete: deletedAt > 0)
if b.deletedAt > 0:
continue
stacks = await nc_client.deck.get_stacks(b.id)
for s in stacks:
if card_found:
break
if s.cards:
for c in s.cards:
if c.id == int(doc_task.doc_id):
card = c
board = b
stack = s
card_found = True
break
if not card_found:
raise ValueError(
f"Deck card {doc_task.doc_id} not found in any board/stack"
)
# Build content from card title and description
content_parts = [card.title]
if card.description:
content_parts.append(card.description)
content = "\n\n".join(content_parts)
title = card.title
# Store deck-specific metadata
file_metadata = {
"board_id": board.id,
"board_title": board.title,
"stack_id": stack.id,
"stack_title": stack.title,
"card_type": card.type,
"duedate": (card.duedate.isoformat() if card.duedate else None),
"archived": card.archived,
"owner": (
card.owner.uid if hasattr(card.owner, "uid") else str(card.owner)
),
}
etag = card.etag or ""
file_path = None
content_bytes = None
content_type = None
elif doc_task.doc_type == "file":
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
if not doc_task.file_path:
@@ -489,18 +358,15 @@ async def _index_document(
chunks = await chunker.chunk_text(content)
# Assign page numbers to chunks if page boundaries are available (PDFs)
page_boundaries = file_metadata.get("page_boundaries")
if doc_task.doc_type == "file" and page_boundaries is not None:
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
with trace_operation(
"vector_sync.assign_page_numbers",
attributes={
"vector_sync.chunk_count": len(chunks),
"vector_sync.page_count": len(page_boundaries_list),
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
},
):
assign_page_numbers(chunks, page_boundaries_list)
assign_page_numbers(chunks, file_metadata["page_boundaries"])
# Diagnostic: Verify page number assignment
assigned_count = sum(1 for c in chunks if c.page_number is not None)
@@ -523,8 +389,8 @@ async def _index_document(
f"Text length: {len(content)}, "
f"Chunks: {len(chunks)}, "
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
f"Page boundaries: {len(page_boundaries_list)} pages, "
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
)
# Extract chunk texts for embedding
@@ -598,9 +464,6 @@ async def _index_document(
logger.warning("No page boundaries available, skipping highlighting")
return
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
logger.info(
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
)
@@ -611,7 +474,7 @@ async def _index_document(
lambda: PDFHighlighter.highlight_chunks_batch(
pdf_bytes=content_bytes,
chunks=chunk_data,
page_boundaries=page_boundaries_list,
page_boundaries=page_boundaries,
full_text=content,
color="yellow",
zoom=2.0,
@@ -703,37 +566,6 @@ async def _index_document(
if doc_task.doc_type == "file"
else {}
),
# News item-specific metadata
**(
{
"feed_id": file_metadata.get("feed_id"),
"feed_title": file_metadata.get("feed_title"),
"author": file_metadata.get("author"),
"pub_date": file_metadata.get("pub_date"),
"starred": file_metadata.get("starred"),
"unread": file_metadata.get("unread"),
"url": file_metadata.get("url"),
"guid_hash": file_metadata.get("guid_hash"),
"enclosure_link": file_metadata.get("enclosure_link"),
"enclosure_mime": file_metadata.get("enclosure_mime"),
}
if doc_task.doc_type == "news_item"
else {}
),
# Deck card-specific metadata
**(
{
"board_id": file_metadata.get("board_id"),
"board_title": file_metadata.get("board_title"),
"stack_id": file_metadata.get("stack_id"),
"stack_title": file_metadata.get("stack_title"),
"card_type": file_metadata.get("card_type"),
"duedate": file_metadata.get("duedate"),
"owner": file_metadata.get("owner"),
}
if doc_task.doc_type == "deck_card"
else {}
),
# Highlighted page image (PDF only)
**(
{
+5 -426
View File
@@ -36,9 +36,6 @@ class DocumentTask:
operation: str # "index" or "delete"
modified_at: int
file_path: str | None = None # File path for files (when doc_id is file_id)
metadata: dict[str, int | str] | None = (
None # Additional metadata (e.g., board_id/stack_id for deck_card)
)
# Track documents potentially deleted (grace period before actual deletion)
@@ -82,11 +79,9 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
if scroll_result[0]:
timestamps = [
point.payload.get("indexed_at", 0)
for point in scroll_result[0]
if point.payload is not None
point.payload.get("indexed_at", 0) for point in scroll_result[0]
]
max_timestamp = max(timestamps) if timestamps else 0
max_timestamp = max(timestamps)
logger.info(
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
)
@@ -211,11 +206,7 @@ async def scan_user_documents(
limit=10000,
)
indexed_doc_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
@@ -385,9 +376,7 @@ async def scan_user_documents(
)
indexed_file_ids = {
point.payload["doc_id"]
for point in (file_scroll_result[0] or [])
if point.payload is not None
point.payload["doc_id"] for point in file_scroll_result[0]
}
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
@@ -555,419 +544,9 @@ async def scan_user_documents(
queued += file_queued
# Scan News items (starred + unread)
news_queued = 0
try:
news_queued = await scan_news_items(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += news_queued
except Exception as e:
logger.warning(f"Failed to scan news items for {user_id}: {e}")
# Scan Deck cards
deck_queued = 0
try:
deck_queued = await scan_deck_cards(
user_id=user_id,
send_stream=send_stream,
nc_client=nc_client,
initial_sync=initial_sync,
scan_id=scan_id,
)
queued += deck_queued
except Exception as e:
logger.warning(f"Failed to scan deck cards for {user_id}: {e}")
if queued > 0:
logger.info(
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
)
else:
logger.debug(f"No changes detected for {user_id}")
async def scan_news_items(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan user's News items and queue changed items for indexing.
Indexes all items from the user's feeds. The News app's auto-purge
feature (default: 200 items per feed) naturally limits the total
number of items, making explicit filtering unnecessary.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of items queued for processing
"""
from nextcloud_mcp_server.client.news import NewsItemType
settings = get_settings()
queued = 0
# Get indexed news item IDs from Qdrant (for deletion tracking)
indexed_item_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value="news_item")),
]
),
with_payload=["doc_id"],
with_vectors=False,
limit=10000,
)
indexed_item_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_item_ids)} indexed news items in Qdrant")
# Fetch all items (News app caps at ~200 per feed via auto-purge)
all_items = await nc_client.news.get_items(
batch_size=-1,
type_=NewsItemType.ALL,
get_read=True,
)
logger.debug(f"[SCAN-{scan_id}] Found {len(all_items)} news items")
item_count = len(all_items)
nextcloud_item_ids: set[str] = set()
for item in all_items:
doc_id = str(item["id"])
nextcloud_item_ids.add(doc_id)
# Use lastModified timestamp (microseconds in News API)
modified_at = item.get("lastModified", 0)
# Convert to seconds if needed (News API uses microseconds)
if modified_at > 10000000000: # > year 2286 in seconds
modified_at = modified_at // 1000000
if initial_sync:
# Send everything on first sync - write placeholder first
await write_placeholder_point(
doc_id=doc_id,
doc_type="news_item",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="index",
modified_at=modified_at,
)
)
queued += 1
else:
# Incremental sync: check if item exists and compare modified_at
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
f"News item {doc_id} reappeared, removing from deletion grace period"
)
del _potentially_deleted[doc_key]
# Query Qdrant for existing entry
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="news_item", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
f"Found stale placeholder for news item {doc_id} "
f"(age={placeholder_age:.1f}s), requeuing"
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="news_item",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="index",
modified_at=modified_at,
)
)
queued += 1
logger.info(
f"[SCAN-{scan_id}] Found {item_count} news items (starred+unread) for {user_id}"
)
record_vector_sync_scan(item_count)
# Check for deleted items (not initial sync)
# Items become "deleted" when they are no longer starred AND become read
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_item_ids:
if doc_id not in nextcloud_item_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
f"News item {doc_id} missing for {time_missing:.1f}s "
f"(>{grace_period:.1f}s grace period), sending deletion"
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="news_item",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
f"News item {doc_id} missing for first time, starting grace period"
)
_potentially_deleted[doc_key] = current_time
return queued
async def scan_deck_cards(
user_id: str,
send_stream: MemoryObjectSendStream[DocumentTask],
nc_client: NextcloudClient,
initial_sync: bool,
scan_id: int,
) -> int:
"""
Scan user's Deck cards and queue changed cards for indexing.
Indexes cards from all non-archived boards and stacks.
Args:
user_id: User to scan
send_stream: Stream to send changed documents to processors
nc_client: Authenticated Nextcloud client
initial_sync: If True, send all documents (first-time sync)
scan_id: Scan identifier for logging
Returns:
Number of cards queued for processing
"""
settings = get_settings()
queued = 0
# Get indexed deck card IDs from Qdrant (for deletion tracking)
indexed_card_ids: set[str] = set()
if not initial_sync:
qdrant_client = await get_qdrant_client()
scroll_result = await qdrant_client.scroll(
collection_name=settings.get_collection_name(),
scroll_filter=Filter(
must=[
FieldCondition(key="user_id", match=MatchValue(value=user_id)),
FieldCondition(key="doc_type", match=MatchValue(value="deck_card")),
]
),
with_payload=["doc_id"],
with_vectors=False,
limit=10000,
)
indexed_card_ids = {
point.payload["doc_id"]
for point in (scroll_result[0] or [])
if point.payload is not None
}
logger.debug(f"Found {len(indexed_card_ids)} indexed deck cards in Qdrant")
# Fetch all boards
boards = await nc_client.deck.get_boards()
logger.debug(f"[SCAN-{scan_id}] Found {len(boards)} deck boards")
card_count = 0
nextcloud_card_ids: set[str] = set()
# Iterate through boards
for board in boards:
# Skip archived boards
if board.archived:
continue
# Skip deleted boards (soft delete: deletedAt > 0)
if board.deletedAt > 0:
logger.debug(f"[SCAN-{scan_id}] Skipping deleted board {board.id}")
continue
# Get stacks for this board
stacks = await nc_client.deck.get_stacks(board.id)
# Iterate through stacks
for stack in stacks:
# Skip if stack has no cards
if not stack.cards:
continue
# Iterate through cards in stack
for card in stack.cards:
# Skip archived cards
if card.archived:
continue
card_count += 1
doc_id = str(card.id)
nextcloud_card_ids.add(doc_id)
# Use lastModified timestamp if available
modified_at = card.lastModified or 0
if initial_sync:
# Send everything on first sync - write placeholder first
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
else:
# Incremental sync: check if card exists and compare modified_at
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
logger.debug(
f"Deck card {doc_id} reappeared, removing from deletion grace period"
)
del _potentially_deleted[doc_key]
# Query Qdrant for existing entry
existing_metadata = await query_document_metadata(
doc_id=doc_id, doc_type="deck_card", user_id=user_id
)
needs_indexing = False
if existing_metadata is None:
needs_indexing = True
elif existing_metadata.get("modified_at", 0) < modified_at:
needs_indexing = True
elif existing_metadata.get("is_placeholder", False):
queued_at = existing_metadata.get("queued_at", 0)
placeholder_age = time.time() - queued_at
stale_threshold = settings.vector_sync_scan_interval * 5
if placeholder_age > stale_threshold:
logger.debug(
f"Found stale placeholder for deck card {doc_id} "
f"(age={placeholder_age:.1f}s), requeuing"
)
needs_indexing = True
if needs_indexing:
await write_placeholder_point(
doc_id=doc_id,
doc_type="deck_card",
user_id=user_id,
modified_at=modified_at,
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="index",
modified_at=modified_at,
metadata={"board_id": board.id, "stack_id": stack.id},
)
)
queued += 1
logger.info(
f"[SCAN-{scan_id}] Found {card_count} deck cards (non-archived) for {user_id}"
)
record_vector_sync_scan(card_count)
# Check for deleted cards (not initial sync)
if not initial_sync:
grace_period = settings.vector_sync_scan_interval * 1.5
current_time = time.time()
for doc_id in indexed_card_ids:
if doc_id not in nextcloud_card_ids:
doc_key = (user_id, doc_id)
if doc_key in _potentially_deleted:
first_missing_time = _potentially_deleted[doc_key]
time_missing = current_time - first_missing_time
if time_missing >= grace_period:
logger.info(
f"Deck card {doc_id} missing for {time_missing:.1f}s "
f"(>{grace_period:.1f}s grace period), sending deletion"
)
await send_stream.send(
DocumentTask(
user_id=user_id,
doc_id=doc_id,
doc_type="deck_card",
operation="delete",
modified_at=0,
)
)
queued += 1
del _potentially_deleted[doc_key]
else:
logger.debug(
f"Deck card {doc_id} missing for first time, starting grace period"
)
_potentially_deleted[doc_key] = current_time
return queued
+2 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.52.0"
version = "0.47.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
requires-python = ">=3.11"
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
dependencies = [
"mcp[cli] (>=1.23,<1.24)",
"mcp[cli] (>=1.22,<1.23)",
"httpx (>=0.28.1,<0.29.0)",
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
"icalendar (>=6.0.0,<7.0.0)",
@@ -36,7 +36,6 @@ dependencies = [
"python-json-logger>=3.2.0", # Structured JSON logging
"jinja2>=3.1.6",
"langchain-text-splitters>=1.0.0",
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
"pymupdf>=1.26.6",
"pymupdf4llm>=0.2.2",
"pymupdf-layout>=1.26.6",
@@ -101,7 +100,6 @@ extend-select = ["I"]
[tool.uv.sources]
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
qdrant-client = { git = "https://github.com/cbcoutinho/qdrant-client", branch = "fix/fusion-score-threshold" }
[build-system]
requires = ["uv_build>=0.9.4,<0.10.0"]
+1 -7
View File
@@ -4,11 +4,5 @@
"config:best-practices",
"mergeConfidence:all-badges"
],
"dependencyDashboard": true,
"packageRules": [
{
"matchPackageNames": ["pillow"],
"allowedVersions": "<12.0.0"
}
]
"dependencyDashboard": true
}
-219
View File
@@ -480,222 +480,3 @@ def create_mock_table_row_ocs_response(
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
return create_mock_response(status_code=200, json_data=ocs_response)
# ============================================================================
# News Mock Response Helpers
# ============================================================================
def create_mock_news_folders_response(
folders: list[dict] | None = None,
) -> httpx.Response:
"""Create a mock response for News folders list.
Args:
folders: List of folder dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with folders data
"""
if folders is None:
folders = []
return create_mock_response(status_code=200, json_data={"folders": folders})
def create_mock_news_folder_response(
folder_id: int = 1,
name: str = "Test Folder",
**kwargs,
) -> httpx.Response:
"""Create a mock response for a News folder.
Args:
folder_id: Folder ID
name: Folder name
**kwargs: Additional folder fields
Returns:
Mock httpx.Response with folder data
"""
folder_data = {
"id": folder_id,
"name": name,
**kwargs,
}
return create_mock_response(status_code=200, json_data={"folders": [folder_data]})
def create_mock_news_feeds_response(
feeds: list[dict] | None = None,
starred_count: int = 0,
newest_item_id: int | None = None,
) -> httpx.Response:
"""Create a mock response for News feeds list.
Args:
feeds: List of feed dictionaries. If None, returns empty list.
starred_count: Number of starred items
newest_item_id: ID of newest item
Returns:
Mock httpx.Response with feeds data
"""
if feeds is None:
feeds = []
data = {
"feeds": feeds,
"starredCount": starred_count,
}
if newest_item_id is not None:
data["newestItemId"] = newest_item_id
return create_mock_response(status_code=200, json_data=data)
def create_mock_news_feed_response(
feed_id: int = 1,
url: str = "https://example.com/feed",
title: str = "Test Feed",
favicon_link: str | None = None,
folder_id: int | None = None,
unread_count: int = 0,
**kwargs,
) -> httpx.Response:
"""Create a mock response for a News feed.
Args:
feed_id: Feed ID
url: Feed URL
title: Feed title
favicon_link: Favicon URL
folder_id: Parent folder ID
unread_count: Number of unread items
**kwargs: Additional feed fields
Returns:
Mock httpx.Response with feed data
"""
feed_data = {
"id": feed_id,
"url": url,
"title": title,
"faviconLink": favicon_link,
"folderId": folder_id,
"unreadCount": unread_count,
"link": kwargs.get("link", "https://example.com"),
"added": kwargs.get("added", 1700000000),
"updateErrorCount": kwargs.get("updateErrorCount", 0),
"lastUpdateError": kwargs.get("lastUpdateError"),
**{
k: v
for k, v in kwargs.items()
if k not in ["link", "added", "updateErrorCount", "lastUpdateError"]
},
}
return create_mock_response(status_code=200, json_data={"feeds": [feed_data]})
def create_mock_news_items_response(
items: list[dict] | None = None,
) -> httpx.Response:
"""Create a mock response for News items list.
Args:
items: List of item dictionaries. If None, returns empty list.
Returns:
Mock httpx.Response with items data
"""
if items is None:
items = []
return create_mock_response(status_code=200, json_data={"items": items})
def create_mock_news_item(
item_id: int = 1,
feed_id: int = 1,
title: str = "Test Article",
body: str = "<p>Test content</p>",
url: str = "https://example.com/article",
author: str | None = "Test Author",
pub_date: int = 1700000000,
unread: bool = True,
starred: bool = False,
**kwargs,
) -> dict:
"""Create a mock News item dictionary.
Args:
item_id: Item ID
feed_id: Parent feed ID
title: Article title
body: Article body (HTML)
url: Article URL
author: Article author
pub_date: Publication timestamp (Unix)
unread: Whether item is unread
starred: Whether item is starred
**kwargs: Additional item fields
Returns:
Item dictionary
"""
return {
"id": item_id,
"feedId": feed_id,
"title": title,
"body": body,
"url": url,
"author": author,
"pubDate": pub_date,
"unread": unread,
"starred": starred,
"guid": kwargs.get("guid", f"guid-{item_id}"),
"guidHash": kwargs.get("guidHash", f"hash-{item_id}"),
"lastModified": kwargs.get("lastModified", pub_date * 1000000),
"enclosureLink": kwargs.get("enclosureLink"),
"enclosureMime": kwargs.get("enclosureMime"),
"fingerprint": kwargs.get("fingerprint", f"fp-{item_id}"),
"contentHash": kwargs.get("contentHash", f"ch-{item_id}"),
**{
k: v
for k, v in kwargs.items()
if k
not in [
"guid",
"guidHash",
"lastModified",
"enclosureLink",
"enclosureMime",
"fingerprint",
"contentHash",
]
},
}
def create_mock_news_status_response(
version: str = "25.0.0",
warnings: dict | None = None,
) -> httpx.Response:
"""Create a mock response for News status.
Args:
version: News app version
warnings: Warning messages
Returns:
Mock httpx.Response with status data
"""
data = {
"version": version,
"warnings": warnings or {},
}
return create_mock_response(status_code=200, json_data=data)
View File
-580
View File
@@ -1,580 +0,0 @@
"""Unit tests for NewsClient API methods."""
import logging
import httpx
import pytest
from nextcloud_mcp_server.client.news import NewsClient, NewsItemType
from tests.client.conftest import (
create_mock_error_response,
create_mock_news_feed_response,
create_mock_news_feeds_response,
create_mock_news_folder_response,
create_mock_news_folders_response,
create_mock_news_item,
create_mock_news_items_response,
create_mock_news_status_response,
create_mock_response,
)
logger = logging.getLogger(__name__)
# Mark all tests in this module as unit tests
pytestmark = pytest.mark.unit
# ============================================================================
# Folder Tests
# ============================================================================
async def test_news_api_get_folders(mocker):
"""Test that get_folders correctly parses the API response."""
mock_response = create_mock_news_folders_response(
folders=[
{"id": 1, "name": "Tech"},
{"id": 2, "name": "News"},
]
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
folders = await client.get_folders()
assert len(folders) == 2
assert folders[0]["id"] == 1
assert folders[0]["name"] == "Tech"
assert folders[1]["name"] == "News"
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/folders")
async def test_news_api_create_folder(mocker):
"""Test that create_folder correctly creates a folder."""
mock_response = create_mock_news_folder_response(folder_id=3, name="New Folder")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
folder = await client.create_folder(name="New Folder")
assert folder["id"] == 3
assert folder["name"] == "New Folder"
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/folders", json={"name": "New Folder"}
)
async def test_news_api_rename_folder(mocker):
"""Test that rename_folder makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.rename_folder(folder_id=1, name="Renamed")
mock_make_request.assert_called_once_with(
"PUT", "/apps/news/api/v1-3/folders/1", json={"name": "Renamed"}
)
async def test_news_api_delete_folder(mocker):
"""Test that delete_folder makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.delete_folder(folder_id=1)
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/folders/1")
# ============================================================================
# Feed Tests
# ============================================================================
async def test_news_api_get_feeds(mocker):
"""Test that get_feeds correctly parses the API response."""
mock_response = create_mock_news_feeds_response(
feeds=[
{"id": 1, "url": "https://example.com/feed1", "title": "Feed 1"},
{"id": 2, "url": "https://example.com/feed2", "title": "Feed 2"},
],
starred_count=5,
newest_item_id=100,
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_feeds()
assert len(result["feeds"]) == 2
assert result["starredCount"] == 5
assert result["newestItemId"] == 100
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/feeds")
async def test_news_api_create_feed(mocker):
"""Test that create_feed correctly creates a feed."""
mock_response = create_mock_news_feed_response(
feed_id=10, url="https://example.com/new-feed", title="New Feed"
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
feed = await client.create_feed(url="https://example.com/new-feed")
assert feed["id"] == 10
assert feed["url"] == "https://example.com/new-feed"
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds",
json={"url": "https://example.com/new-feed"},
)
async def test_news_api_create_feed_with_folder(mocker):
"""Test that create_feed correctly creates a feed in a folder."""
mock_response = create_mock_news_feed_response(
feed_id=10, url="https://example.com/feed", folder_id=5
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
feed = await client.create_feed(url="https://example.com/feed", folder_id=5)
assert feed["folderId"] == 5
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds",
json={"url": "https://example.com/feed", "folderId": 5},
)
async def test_news_api_delete_feed(mocker):
"""Test that delete_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.delete_feed(feed_id=10)
mock_make_request.assert_called_once_with("DELETE", "/apps/news/api/v1-3/feeds/10")
async def test_news_api_move_feed(mocker):
"""Test that move_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.move_feed(feed_id=10, folder_id=5)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/feeds/10/move", json={"folderId": 5}
)
async def test_news_api_rename_feed(mocker):
"""Test that rename_feed makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.rename_feed(feed_id=10, title="Renamed Feed")
mock_make_request.assert_called_once_with(
"POST",
"/apps/news/api/v1-3/feeds/10/rename",
json={"feedTitle": "Renamed Feed"},
)
# ============================================================================
# Item Tests
# ============================================================================
async def test_news_api_get_items(mocker):
"""Test that get_items correctly parses the API response."""
items = [
create_mock_news_item(item_id=1, title="Article 1"),
create_mock_news_item(item_id=2, title="Article 2"),
]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items()
assert len(result) == 2
assert result[0]["title"] == "Article 1"
assert result[1]["title"] == "Article 2"
# Verify default parameters
call_args = mock_make_request.call_args
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items")
params = call_args[1]["params"]
assert params["batchSize"] == 50
assert params["type"] == NewsItemType.ALL
async def test_news_api_get_items_starred(mocker):
"""Test that get_items with STARRED type filters correctly."""
items = [create_mock_news_item(item_id=1, starred=True)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items(type_=NewsItemType.STARRED)
assert len(result) == 1
assert result[0]["starred"] is True
call_args = mock_make_request.call_args
params = call_args[1]["params"]
assert params["type"] == NewsItemType.STARRED
async def test_news_api_get_items_unread_only(mocker):
"""Test that get_items with get_read=False filters correctly."""
items = [create_mock_news_item(item_id=1, unread=True)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_items(get_read=False)
assert len(result) == 1
call_args = mock_make_request.call_args
params = call_args[1]["params"]
assert params["getRead"] == "false"
async def test_news_api_get_item(mocker):
"""Test that get_item fetches all items and filters for the requested ID."""
# Create multiple items, only one should be returned
items = [
create_mock_news_item(item_id=100, title="Other Item 1"),
create_mock_news_item(item_id=123, title="Single Item"),
create_mock_news_item(item_id=200, title="Other Item 2"),
]
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_get_items = mocker.patch.object(NewsClient, "get_items", return_value=items)
client = NewsClient(mock_client, "testuser")
result = await client.get_item(item_id=123)
assert result["id"] == 123
assert result["title"] == "Single Item"
# Verify it fetched all items with correct params
mock_get_items.assert_called_once_with(batch_size=-1, get_read=True)
async def test_news_api_get_item_not_found(mocker):
"""Test that get_item raises ValueError when item not found."""
items = [
create_mock_news_item(item_id=100, title="Item 1"),
create_mock_news_item(item_id=200, title="Item 2"),
]
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mocker.patch.object(NewsClient, "get_items", return_value=items)
client = NewsClient(mock_client, "testuser")
with pytest.raises(ValueError, match="Item 999 not found"):
await client.get_item(item_id=999)
async def test_news_api_get_updated_items(mocker):
"""Test that get_updated_items correctly calls the updated endpoint."""
items = [create_mock_news_item(item_id=1)]
mock_response = create_mock_news_items_response(items=items)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
result = await client.get_updated_items(last_modified=1700000000)
assert len(result) == 1
call_args = mock_make_request.call_args
assert call_args[0] == ("GET", "/apps/news/api/v1-3/items/updated")
params = call_args[1]["params"]
assert params["lastModified"] == 1700000000
async def test_news_api_mark_item_read(mocker):
"""Test that mark_item_read makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_item_read(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/read"
)
async def test_news_api_mark_item_unread(mocker):
"""Test that mark_item_unread makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_item_unread(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/unread"
)
async def test_news_api_star_item(mocker):
"""Test that star_item makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.star_item(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/star"
)
async def test_news_api_unstar_item(mocker):
"""Test that unstar_item makes the correct API call."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.unstar_item(item_id=123)
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/123/unstar"
)
async def test_news_api_mark_items_read_multiple(mocker):
"""Test that mark_items_read makes the correct API call for multiple items."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.mark_items_read(item_ids=[1, 2, 3])
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/read/multiple", json={"itemIds": [1, 2, 3]}
)
async def test_news_api_star_items_multiple(mocker):
"""Test that star_items makes the correct API call for multiple items."""
mock_response = create_mock_response(status_code=200, json_data={})
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
await client.star_items(item_ids=[1, 2, 3])
mock_make_request.assert_called_once_with(
"POST", "/apps/news/api/v1-3/items/star/multiple", json={"itemIds": [1, 2, 3]}
)
# ============================================================================
# Status Tests
# ============================================================================
async def test_news_api_get_status(mocker):
"""Test that get_status correctly parses the API response."""
mock_response = create_mock_news_status_response(
version="25.0.0",
warnings={"improperlyConfiguredCron": False},
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
status = await client.get_status()
assert status["version"] == "25.0.0"
assert "warnings" in status
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/status")
async def test_news_api_get_version(mocker):
"""Test that get_version correctly parses the API response."""
mock_response = create_mock_response(
status_code=200, json_data={"version": "25.0.0"}
)
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(
NewsClient, "_make_request", return_value=mock_response
)
client = NewsClient(mock_client, "testuser")
version = await client.get_version()
assert version == "25.0.0"
mock_make_request.assert_called_once_with("GET", "/apps/news/api/v1-3/version")
# ============================================================================
# Error Handling Tests
# ============================================================================
async def test_news_api_create_folder_conflict(mocker):
"""Test that create_folder raises HTTPStatusError on 409 conflict."""
error_response = create_mock_error_response(409, "Folder name already exists")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"409 Conflict",
request=httpx.Request("POST", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.create_folder(name="Existing Folder")
assert excinfo.value.response.status_code == 409
async def test_news_api_delete_feed_not_found(mocker):
"""Test that delete_feed raises HTTPStatusError on 404."""
error_response = create_mock_error_response(404, "Feed not found")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=httpx.Request("DELETE", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.delete_feed(feed_id=999999)
assert excinfo.value.response.status_code == 404
async def test_news_api_create_feed_invalid_url(mocker):
"""Test that create_feed raises HTTPStatusError on 422 for invalid URL."""
error_response = create_mock_error_response(422, "Invalid feed URL")
mock_client = mocker.AsyncMock(spec=httpx.AsyncClient)
mock_make_request = mocker.patch.object(NewsClient, "_make_request")
mock_make_request.side_effect = httpx.HTTPStatusError(
"422 Unprocessable Entity",
request=httpx.Request("POST", "http://test.local"),
response=error_response,
)
client = NewsClient(mock_client, "testuser")
with pytest.raises(httpx.HTTPStatusError) as excinfo:
await client.create_feed(url="not-a-valid-url")
assert excinfo.value.response.status_code == 422
+55 -1
View File
@@ -9,6 +9,7 @@ import pytest
from httpx import HTTPStatusError
from mcp import ClientSession
from mcp.client.session import RequestContext
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import ElicitRequestParams, ElicitResult, ErrorData
@@ -171,6 +172,51 @@ async def create_mcp_client_session(
logger.debug(f"{client_name} client session cleaned up successfully")
async def create_mcp_client_session_sse(
url: str,
token: str | None = None,
client_name: str = "MCP",
elicitation_callback: Any = None,
) -> AsyncGenerator[ClientSession, Any]:
"""
Factory function to create an MCP client session using SSE transport.
Similar to create_mcp_client_session but uses SSE transport instead of streamable-http.
Uses native async context managers to ensure correct LIFO cleanup order.
Args:
url: MCP server URL (e.g., "http://localhost:8000/sse")
token: Optional OAuth access token for Bearer authentication
client_name: Client name for logging (e.g., "Basic MCP (SSE)")
elicitation_callback: Optional callback for handling elicitation requests
Yields:
Initialized MCP ClientSession
Note:
SSE transport is being deprecated in favor of streamable-http.
This function exists for compatibility testing only.
"""
logger.info(f"Creating SSE client for {client_name}")
# Prepare headers with OAuth token if provided
headers = {"Authorization": f"Bearer {token}"} if token else None
# Use native async with - Python ensures LIFO cleanup
# Cleanup order will be: ClientSession.__aexit__ -> sse_client.__aexit__
# Note: sse_client yields only (read_stream, write_stream), not 3 values like streamablehttp_client
async with sse_client(url, headers=headers) as (read_stream, write_stream):
async with ClientSession(
read_stream, write_stream, elicitation_callback=elicitation_callback
) as session:
await session.initialize()
logger.info(f"{client_name} client session initialized successfully")
yield session
# Cleanup happens automatically in LIFO order - no exception suppression needed
logger.debug(f"{client_name} client session cleaned up successfully")
@pytest.fixture(scope="session")
async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
"""
@@ -209,10 +255,18 @@ async def nc_client(anyio_backend) -> AsyncGenerator[NextcloudClient, Any]:
@pytest.fixture(scope="session")
async def nc_mcp_client(anyio_backend) -> AsyncGenerator[ClientSession, Any]:
"""
Fixture to create an MCP client session for integration tests using streamable-http.
Fixture to create an MCP client session for integration tests using SSE transport.
Uses anyio pytest plugin for proper async fixture handling.
Note: SSE transport is being deprecated. This fixture uses SSE for compatibility testing.
"""
# async for session in create_mcp_client_session_sse(
# url="http://localhost:8000/sse", client_name="Basic MCP (SSE)"
# ):
# yield session
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
client_name="Basic MCP (HTTP)",
-26
View File
@@ -1,26 +0,0 @@
"""Pytest configuration for integration tests.
This conftest.py provides hooks and fixtures specific to integration tests,
including the --provider flag for RAG tests.
"""
# Valid provider names
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
def pytest_addoption(parser):
"""Add --provider command line option for RAG tests."""
parser.addoption(
"--provider",
action="store",
default=None,
choices=VALID_PROVIDERS,
help="LLM provider for RAG tests: openai, ollama, anthropic, bedrock",
)
def pytest_configure(config):
"""Configure custom markers."""
config.addinivalue_line(
"markers", "rag: mark test as RAG integration test (requires --provider flag)"
)
-264
View File
@@ -1,264 +0,0 @@
"""Provider fixtures for integration tests.
This module provides pytest fixtures that configure LLM providers based on
an explicit --provider flag. Supports OpenAI, Ollama, Anthropic, and Bedrock.
Usage:
pytest tests/integration/test_rag.py --provider=openai
pytest tests/integration/test_rag.py --provider=ollama
pytest tests/integration/test_rag.py --provider=anthropic
pytest tests/integration/test_rag.py --provider=bedrock
Environment Variables by Provider:
OpenAI:
OPENAI_API_KEY: API key (required)
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model (default: "gpt-4o-mini")
Ollama:
OLLAMA_BASE_URL: API URL (required, e.g., "http://localhost:11434")
OLLAMA_EMBEDDING_MODEL: Embedding model (default: "nomic-embed-text")
OLLAMA_GENERATION_MODEL: Generation model (default: "llama3.2:1b")
Anthropic:
ANTHROPIC_API_KEY: API key (required)
ANTHROPIC_GENERATION_MODEL: Model (default: "claude-3-haiku-20240307")
Bedrock:
AWS_REGION: AWS region (required)
BEDROCK_EMBEDDING_MODEL: Embedding model ID
BEDROCK_GENERATION_MODEL: Generation model ID
"""
import logging
import os
from typing import AsyncGenerator
import pytest
from nextcloud_mcp_server.providers.base import Provider
logger = logging.getLogger(__name__)
# Valid provider names (must match conftest.py)
VALID_PROVIDERS = ["openai", "ollama", "anthropic", "bedrock"]
async def create_generation_provider(provider_name: str) -> Provider:
"""Create a provider configured for text generation.
Args:
provider_name: One of "openai", "ollama", "anthropic", "bedrock"
Returns:
Provider instance configured for generation
Raises:
ValueError: If provider_name is invalid or required env vars missing
"""
if provider_name == "openai":
from nextcloud_mcp_server.providers.openai import OpenAIProvider
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable required")
base_url = os.getenv("OPENAI_BASE_URL")
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
# GitHub Models API requires model name prefix
if base_url and "models.github.ai" in base_url:
if not generation_model.startswith("openai/"):
generation_model = f"openai/{generation_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created OpenAI generation provider: model={generation_model}")
return provider
elif provider_name == "ollama":
from nextcloud_mcp_server.providers.ollama import OllamaProvider
base_url = os.getenv("OLLAMA_BASE_URL")
if not base_url:
raise ValueError("OLLAMA_BASE_URL environment variable required")
generation_model = os.getenv("OLLAMA_GENERATION_MODEL", "llama3.2:1b")
provider = OllamaProvider(
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created Ollama generation provider: model={generation_model}")
return provider
elif provider_name == "anthropic":
from nextcloud_mcp_server.providers.anthropic import AnthropicProvider
api_key = os.getenv("ANTHROPIC_API_KEY")
if not api_key:
raise ValueError("ANTHROPIC_API_KEY environment variable required")
generation_model = os.getenv(
"ANTHROPIC_GENERATION_MODEL", "claude-3-haiku-20240307"
)
provider = AnthropicProvider(
api_key=api_key,
generation_model=generation_model,
)
logger.info(f"Created Anthropic generation provider: model={generation_model}")
return provider
elif provider_name == "bedrock":
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
region = os.getenv("AWS_REGION")
if not region:
raise ValueError("AWS_REGION environment variable required")
generation_model = os.getenv("BEDROCK_GENERATION_MODEL")
if not generation_model:
raise ValueError("BEDROCK_GENERATION_MODEL environment variable required")
provider = BedrockProvider(
region=region,
embedding_model=None, # Generation only
generation_model=generation_model,
)
logger.info(f"Created Bedrock generation provider: model={generation_model}")
return provider
else:
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
async def create_embedding_provider(provider_name: str) -> Provider:
"""Create a provider configured for embeddings.
Args:
provider_name: One of "openai", "ollama", "bedrock"
(Anthropic does not support embeddings)
Returns:
Provider instance configured for embeddings
Raises:
ValueError: If provider_name is invalid, doesn't support embeddings,
or required env vars missing
"""
if provider_name == "anthropic":
raise ValueError("Anthropic does not support embeddings")
if provider_name == "openai":
from nextcloud_mcp_server.providers.openai import OpenAIProvider
api_key = os.getenv("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable required")
base_url = os.getenv("OPENAI_BASE_URL")
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
# GitHub Models API requires model name prefix
if base_url and "models.github.ai" in base_url:
if not embedding_model.startswith("openai/"):
embedding_model = f"openai/{embedding_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created OpenAI embedding provider: model={embedding_model}")
return provider
elif provider_name == "ollama":
from nextcloud_mcp_server.providers.ollama import OllamaProvider
base_url = os.getenv("OLLAMA_BASE_URL")
if not base_url:
raise ValueError("OLLAMA_BASE_URL environment variable required")
embedding_model = os.getenv("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
provider = OllamaProvider(
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created Ollama embedding provider: model={embedding_model}")
return provider
elif provider_name == "bedrock":
from nextcloud_mcp_server.providers.bedrock import BedrockProvider
region = os.getenv("AWS_REGION")
if not region:
raise ValueError("AWS_REGION environment variable required")
embedding_model = os.getenv("BEDROCK_EMBEDDING_MODEL")
if not embedding_model:
raise ValueError("BEDROCK_EMBEDDING_MODEL environment variable required")
provider = BedrockProvider(
region=region,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
logger.info(f"Created Bedrock embedding provider: model={embedding_model}")
return provider
else:
raise ValueError(f"Unknown provider: {provider_name}. Valid: {VALID_PROVIDERS}")
# =============================================================================
# Pytest Fixtures
# =============================================================================
@pytest.fixture(scope="module")
def provider_name(request) -> str:
"""Get the provider name from --provider flag.
Raises pytest.skip if --provider not specified.
"""
name = request.config.getoption("--provider")
if not name:
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
return name
@pytest.fixture(scope="module")
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Fixture providing a generation-capable provider.
Requires --provider flag to be set.
"""
provider = await create_generation_provider(provider_name)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def embedding_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Fixture providing an embedding-capable provider.
Requires --provider flag to be set.
Note: Anthropic does not support embeddings - test will fail if used.
"""
if provider_name == "anthropic":
pytest.skip("Anthropic does not support embeddings")
provider = await create_embedding_provider(provider_name)
yield provider
await provider.close()
+23 -49
View File
@@ -1,7 +1,7 @@
"""MCP sampling support for integration tests.
This module provides utilities to enable real LLM-based sampling in integration tests
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
using OpenAI or GitHub Models API.
"""
import logging
@@ -10,58 +10,46 @@ from typing import Any
from mcp import types
from mcp.client.session import ClientSession, RequestContext
from nextcloud_mcp_server.providers.base import Provider
from nextcloud_mcp_server.providers.openai import OpenAIProvider
logger = logging.getLogger(__name__)
def create_sampling_callback(provider: Provider):
"""Factory to create a sampling callback using any generation-capable provider.
def create_openai_sampling_callback(provider: OpenAIProvider):
"""Factory to create a sampling callback using OpenAI provider.
The callback conforms to MCP's SamplingFnT protocol and can be passed
to ClientSession for handling sampling requests from the server.
Args:
provider: Any Provider instance that supports generation
(supports_generation=True)
provider: OpenAIProvider instance configured with a generation model
Returns:
Async callback function for MCP sampling
Raises:
ValueError: If provider doesn't support generation
Example:
```python
from nextcloud_mcp_server.providers import get_provider
provider = OpenAIProvider(
api_key=os.getenv("OPENAI_API_KEY"),
base_url=os.getenv("OPENAI_BASE_URL"),
generation_model="gpt-4o-mini",
)
callback = create_openai_sampling_callback(provider)
provider = get_provider() # Auto-detect from environment
if provider.supports_generation:
callback = create_sampling_callback(provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
sampling_callback=callback,
):
# Session now supports sampling
pass
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
sampling_callback=callback,
):
# Session now supports sampling
pass
```
"""
if not provider.supports_generation:
raise ValueError(
f"Provider {provider.__class__.__name__} does not support generation"
)
# Get model name for logging (provider-specific attribute)
model_name = (
getattr(provider, "generation_model", None) or provider.__class__.__name__
)
async def sampling_callback(
context: RequestContext[ClientSession, Any],
params: types.CreateMessageRequestParams,
) -> types.CreateMessageResult | types.ErrorData:
"""Handle sampling requests using the configured provider."""
"""Handle sampling requests using OpenAI provider."""
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
# Extract messages and build prompt
@@ -80,13 +68,14 @@ def create_sampling_callback(provider: Provider):
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
try:
# Generate response using provider
# Note: temperature is typically hardcoded in providers at 0.7
# Generate response using OpenAI provider
# Note: temperature is hardcoded in the provider at 0.7
response = await provider.generate(
prompt=prompt,
max_tokens=params.maxTokens,
)
model_name = provider.generation_model or "unknown"
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
return types.CreateMessageResult(
@@ -96,25 +85,10 @@ def create_sampling_callback(provider: Provider):
stopReason="endTurn",
)
except Exception as e:
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
logger.error(f"OpenAI generation failed: {e}")
return types.ErrorData(
code=types.INTERNAL_ERROR,
message=f"Generation failed: {e!s}",
message=f"OpenAI generation failed: {e!s}",
)
return sampling_callback
def create_openai_sampling_callback(provider: "Provider"):
"""Factory to create a sampling callback using OpenAI provider.
This is a backward-compatible wrapper around create_sampling_callback().
Prefer using create_sampling_callback() directly for new code.
Args:
provider: OpenAIProvider instance configured with a generation model
Returns:
Async callback function for MCP sampling
"""
return create_sampling_callback(provider)
@@ -1,238 +0,0 @@
"""Integration tests for Deck card vector search.
These tests validate that Deck cards are properly indexed and searchable
via semantic search.
"""
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
async def test_deck_card_semantic_search(nc_mcp_client, nc_client, mocker):
"""Test that Deck cards can be indexed and searched via semantic search.
This test:
1. Creates a Deck board with a card
2. Manually triggers indexing (simulates vector sync)
3. Performs semantic search filtering by deck_card doc_type
4. Verifies the card is found in results
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board
board_title = "Test Board for Vector Search"
board = await nc_client.deck.create_board(title=board_title, color="ff0000")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Create a test card with searchable content
card_title = "Machine Learning Project Plan"
card_description = """
# ML Project Outline
## Phase 1: Data Collection
- Gather training data from multiple sources
- Clean and preprocess the dataset
## Phase 2: Model Training
- Experiment with different neural network architectures
- Use gradient descent optimization
## Phase 3: Deployment
- Deploy model to production environment
- Monitor performance metrics
"""
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Note: In a real integration test with vector sync enabled,
# we would wait for the background scanner to index the card.
# For now, we'll test the scanning function directly if needed.
# TODO: Once vector sync is running in test environment,
# add actual semantic search test here
# For now, just verify the card was created successfully
assert card.id is not None
assert card.title == card_title
assert card.description == card_description
# Test semantic search with deck_card filter
# Note: This will only work if vector sync is actually running
# and the card has been indexed
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": "machine learning neural networks",
"doc_types": ["deck_card"],
"limit": 10,
},
)
# If vector sync is working, we should find the card
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if our card is in the results
found_card = any(
r.get("doc_type") == "deck_card" and r.get("title") == card_title
for r in results
)
# Log result for debugging
if found_card:
print("✓ Successfully found Deck card in vector search")
else:
print(
"⚠ Deck card not found in search (may need time for indexing)"
)
except Exception as e:
# If search fails, it might be because indexing hasn't happened yet
print(f"⚠ Semantic search failed (indexing may not be complete): {e}")
finally:
# Cleanup: delete the board
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_appears_in_cross_app_search(nc_mcp_client, nc_client):
"""Test that Deck cards appear in cross-app semantic search (no doc_type filter).
This verifies that when searching without specifying doc_types,
Deck cards are included in the results alongside notes, files, etc.
"""
# Skip if vector sync is not enabled
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
if settings_response.isError:
pytest.skip("Vector sync not enabled")
# Create a test board with a distinctive card
board_title = "Cross-App Search Test Board"
board = await nc_client.deck.create_board(title=board_title, color="00ff00")
try:
# Create a stack for the board
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
# Use a very distinctive term to make it easy to find
unique_term = "xylophone_banana_unicorn_test"
_card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=f"Test Card with {unique_term}",
description=f"This card contains the unique search term: {unique_term}",
)
# Test cross-app search (no doc_type filter)
try:
search_result = await nc_mcp_client.call_tool(
"nc_semantic_search",
{
"query": unique_term,
"limit": 20,
},
)
if not search_result.isError:
data = search_result.structuredContent
results = data.get("results", [])
# Check if deck_card appears in cross-app results
deck_cards_found = [
r for r in results if r.get("doc_type") == "deck_card"
]
if deck_cards_found:
print(
f"✓ Found {len(deck_cards_found)} Deck card(s) in cross-app search"
)
else:
print(
"⚠ No Deck cards in cross-app search (may need time for indexing)"
)
except Exception as e:
print(f"⚠ Cross-app search failed: {e}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
async def test_deck_card_chunk_context(nc_client):
"""Test that Deck card chunk context can be fetched for visualization.
This test validates that the vector viz UI can display Deck card previews
by fetching the chunk context via the context expansion module.
"""
from nextcloud_mcp_server.search.context import get_chunk_with_context
# Create board, stack, and card
board = await nc_client.deck.create_board(title="Test Board", color="ff0000")
try:
stack = await nc_client.deck.create_stack(
board_id=board.id, title="Test Stack", order=0
)
card_title = "Test Card for Context Expansion"
card_description = "This is a test description that should be fetched by the context expansion module when displaying chunk previews in the vector visualization UI."
card = await nc_client.deck.create_card(
board_id=board.id,
stack_id=stack.id,
title=card_title,
description=card_description,
)
# Fetch chunk context (simulates viz UI request)
# The chunk spans the title, so start=0 and end=len(card_title)
context = await get_chunk_with_context(
nc_client=nc_client,
user_id=nc_client.username,
doc_id=card.id,
doc_type="deck_card",
chunk_start=0,
chunk_end=len(card_title),
context_chars=100,
)
# Verify context was fetched successfully
assert context is not None, "Chunk context should not be None"
assert card_title in context.chunk_text, (
f"Card title '{card_title}' should be in chunk_text"
)
# Verify context includes description
assert card_description[:50] in context.after_context, (
"Card description should be in after_context"
)
print(f"✓ Successfully fetched chunk context for Deck card {card.id}")
finally:
# Cleanup
try:
await nc_client.deck.delete_board(board.id)
except Exception as e:
print(f"Warning: Failed to cleanup test board: {e}")
-403
View File
@@ -1,403 +0,0 @@
"""Integration tests for RAG pipeline with multiple LLM providers.
These tests validate the complete semantic search and MCP sampling flow using:
1. MCP server's built-in semantic search (embeddings handled server-side)
2. MCP sampling for answer generation (any generation-capable provider)
3. Pre-indexed Nextcloud User Manual as the knowledge base
Usage:
# Run with OpenAI (including GitHub Models API)
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
# Run with Ollama
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
pytest tests/integration/test_rag.py --provider=ollama -v
# Run with Anthropic
ANTHROPIC_API_KEY=... pytest tests/integration/test_rag.py --provider=anthropic -v
# Run with AWS Bedrock
AWS_REGION=us-east-1 BEDROCK_GENERATION_MODEL=... \\
pytest tests/integration/test_rag.py --provider=bedrock -v
Environment Variables:
See tests/integration/provider_fixtures.py for provider-specific configuration.
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud Manual.pdf")
Prerequisites:
- Nextcloud User Manual PDF uploaded to Nextcloud
- VECTOR_SYNC_ENABLED=true on the MCP server
- Provider-specific environment variables set
"""
import json
import logging
import os
from pathlib import Path
from typing import Any, AsyncGenerator
import anyio
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.providers.base import Provider
from tests.conftest import create_mcp_client_session
from tests.integration.provider_fixtures import create_generation_provider
from tests.integration.sampling_support import create_sampling_callback
logger = logging.getLogger(__name__)
# Default path to the Nextcloud User Manual PDF
DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
async def llm_judge(
provider: Provider,
ground_truth: str,
system_output: str,
) -> bool:
"""Use LLM to judge if system output aligns with ground truth.
Args:
provider: Any provider with generation capability
ground_truth: The expected/reference answer
system_output: The system's actual output to evaluate
Returns:
True if output aligns with ground truth, False otherwise
"""
prompt = f"""GROUND TRUTH: {ground_truth}
SYSTEM OUTPUT: {system_output}
Does the system output contain the key facts from the ground truth?
Answer: TRUE or FALSE"""
logger.info("Received ground truth: %s", ground_truth)
logger.info("Received system output: %s", system_output)
response = await provider.generate(prompt, max_tokens=10)
logger.info("LLM Judge response: %s", response)
return "TRUE" in response.upper()
# Mark all tests as integration tests
pytestmark = [
pytest.mark.integration,
pytest.mark.rag,
]
# Ground truth fixture path
FIXTURES_DIR = Path(__file__).parent / "fixtures"
GROUND_TRUTH_FILE = FIXTURES_DIR / "nextcloud_manual_ground_truth.json"
@pytest.fixture(scope="module")
def ground_truth_qa():
"""Load ground truth Q&A pairs for the Nextcloud manual."""
if not GROUND_TRUTH_FILE.exists():
pytest.skip(f"Ground truth file not found: {GROUND_TRUTH_FILE}")
with open(GROUND_TRUTH_FILE) as f:
return json.load(f)
@pytest.fixture(scope="module")
async def indexed_manual_pdf(nc_client, nc_mcp_client):
"""Ensure the Nextcloud User Manual PDF is tagged and indexed for vector search.
This fixture:
1. Gets file info for the manual PDF
2. Creates/gets the 'vector-index' tag
3. Assigns the tag to the file
4. Waits for vector sync to complete indexing
Environment Variables:
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: Nextcloud Manual.pdf)
"""
manual_path = os.getenv("RAG_MANUAL_PATH", DEFAULT_MANUAL_PATH)
logger.info(f"Setting up indexed manual PDF: {manual_path}")
# Get file info to verify file exists and get file ID
file_info = await nc_client.webdav.get_file_info(manual_path)
if not file_info:
pytest.skip(f"Manual PDF not found at '{manual_path}'")
file_id = file_info["id"]
logger.info(f"Found manual PDF: {manual_path} (file_id={file_id})")
# Create or get the vector-index tag
tag = await nc_client.webdav.get_or_create_tag("vector-index")
tag_id = tag["id"]
logger.info(f"Using tag 'vector-index' (tag_id={tag_id})")
# Assign tag to file
await nc_client.webdav.assign_tag_to_file(file_id, tag_id)
logger.info(f"Tagged file {file_id} with vector-index tag")
# Wait for vector sync to complete indexing
max_attempts = 60
poll_interval = 10
logger.info("Waiting for vector sync to index the manual...")
for attempt in range(1, max_attempts + 1):
try:
# Call the MCP tool via the existing client session
result = await nc_mcp_client.call_tool(
"nc_get_vector_sync_status",
arguments={},
)
if not result.isError:
content = result.structuredContent or {}
indexed = content.get("indexed_count", 0)
pending = content.get("pending_count", 1)
logger.info(
f"Attempt {attempt}/{max_attempts}: "
f"indexed={indexed}, pending={pending}"
)
if indexed > 0 and pending == 0:
logger.info(
f"Vector indexing complete: {indexed} documents indexed"
)
break
except Exception as e:
logger.warning(f"Attempt {attempt}: Error checking status: {e}")
if attempt < max_attempts:
await anyio.sleep(poll_interval)
else:
logger.warning(
f"Vector indexing may not be complete after {max_attempts} attempts"
)
yield {
"path": manual_path,
"file_id": file_id,
"tag_id": tag_id,
}
@pytest.fixture(scope="module")
def provider_name(request) -> str:
"""Get the provider name from --provider flag.
Raises pytest.skip if --provider not specified.
"""
name = request.config.getoption("--provider")
if not name:
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
return name
@pytest.fixture(scope="module")
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
"""Provider configured for text generation.
Requires --provider flag to be set.
"""
provider = await create_generation_provider(provider_name)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def nc_mcp_client_with_sampling(
anyio_backend, generation_provider, provider_name
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with sampling support using the specified provider.
This fixture creates an MCP client that can handle sampling requests
from the server using the configured generation provider.
"""
sampling_callback = create_sampling_callback(generation_provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
client_name=f"Sampling MCP ({provider_name})",
sampling_callback=sampling_callback,
):
yield session
async def test_semantic_search_retrieval(
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
):
"""Test that semantic search retrieves relevant documents from the manual.
This tests the retrieval component of RAG - ensuring that queries
return relevant chunks from the indexed Nextcloud User Manual.
"""
# Use first query from ground truth
test_case = ground_truth_qa[0] # 2FA question
query = test_case["query"]
# Perform semantic search via MCP tool
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
},
)
assert result.isError is False, f"Tool call failed: {result}"
data = result.structuredContent
# Verify we got results
assert data["success"] is True
assert data["total_found"] > 0, f"No results for query: {query}"
assert len(data["results"]) > 0
# Use LLM judge to evaluate if excerpts are relevant to ground truth
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
is_relevant = await llm_judge(
generation_provider,
test_case["ground_truth"],
all_excerpts,
)
assert is_relevant, f"LLM judge: excerpts not relevant to query: {query}"
async def test_semantic_search_answer_with_sampling(
nc_mcp_client_with_sampling,
ground_truth_qa,
indexed_manual_pdf,
generation_provider,
):
"""Test semantic search with MCP sampling for answer generation.
This tests the full RAG pipeline:
1. Semantic search retrieves relevant documents
2. MCP sampling generates an answer from the retrieved context
3. Provider generates the answer via the sampling callback
Uses nc_mcp_client_with_sampling which has sampling enabled.
"""
# Use the 2FA question - has clear expected answer
test_case = ground_truth_qa[0]
query = test_case["query"]
result = await nc_mcp_client_with_sampling.call_tool(
"nc_semantic_search_answer",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
"max_answer_tokens": 300,
},
)
assert result.isError is False, f"Tool call failed: {result}"
data = result.structuredContent
# Verify response structure
assert data["success"] is True
assert "query" in data
assert "generated_answer" in data
assert "sources" in data
assert "search_method" in data
# Check for either successful sampling or graceful fallback
fallback_methods = {
"semantic_sampling_unsupported",
"semantic_sampling_user_declined",
"semantic_sampling_timeout",
"semantic_sampling_mcp_error",
"semantic_sampling_fallback",
}
if data["search_method"] in fallback_methods:
# Fallback mode - verify sources still returned
assert len(data["sources"]) > 0, "Expected sources even in fallback mode"
pytest.skip(
f"MCP sampling not available (method: {data['search_method']}), "
f"but retrieval succeeded with {len(data['sources'])} sources"
)
else:
# Successful sampling - verify answer quality
assert data["search_method"] == "semantic_sampling"
assert data["generated_answer"] is not None
assert len(data["generated_answer"]) > 50 # Non-trivial answer
# Use LLM judge to evaluate answer relevance
is_relevant = await llm_judge(
generation_provider,
test_case["ground_truth"],
data["generated_answer"],
)
assert is_relevant, f"LLM judge: answer not relevant to query: {query}"
@pytest.mark.parametrize(
"qa_index,min_expected_results",
[
(0, 1), # 2FA question
(1, 1), # File quotas question
(2, 1), # Linux installation question
(3, 1), # Windows requirements question
(4, 1), # Client apps with 2FA question
],
)
async def test_retrieval_quality_all_queries(
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, qa_index, min_expected_results
):
"""Test retrieval quality for all ground truth queries.
Validates that each query returns at least the minimum expected
number of relevant results from the Nextcloud manual.
"""
if qa_index >= len(ground_truth_qa):
pytest.skip(f"Ground truth index {qa_index} not available")
test_case = ground_truth_qa[qa_index]
query = test_case["query"]
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
},
)
assert result.isError is False
data = result.structuredContent
assert data["total_found"] >= min_expected_results, (
f"Query '{query}' returned {data['total_found']} results, "
f"expected at least {min_expected_results}"
)
async def test_no_results_for_unrelated_query(nc_mcp_client, indexed_manual_pdf):
"""Test that completely unrelated queries return low/no scores.
The Nextcloud manual shouldn't have relevant content for
quantum physics queries.
"""
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": "quantum entanglement hadron collider particle physics",
"limit": 5,
"score_threshold": 0.5, # Higher threshold to filter irrelevant
},
)
assert result.isError is False
data = result.structuredContent
# Should have few or no high-scoring results
# Low score threshold means we might get some results, but they should be low quality
if data["total_found"] > 0:
# If results exist, they should have low scores
max_score = max(r["score"] for r in data["results"])
assert max_score < 0.8, f"Unexpected high score {max_score} for unrelated query"
+300
View File
@@ -0,0 +1,300 @@
"""Integration tests for RAG pipeline with OpenAI/GitHub Models API.
These tests validate the complete semantic search and MCP sampling flow using:
1. OpenAI embeddings for semantic search
2. MCP sampling for answer generation
3. Pre-indexed Nextcloud User Manual as the knowledge base
Environment Variables:
OPENAI_API_KEY: OpenAI API key or GitHub token for models.github.ai
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini")
For GitHub CI, set:
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
OPENAI_BASE_URL: https://models.github.ai/inference
OPENAI_EMBEDDING_MODEL: openai/text-embedding-3-small
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
Prerequisites:
- Nextcloud User Manual indexed in Qdrant (via vector sync)
- VECTOR_SYNC_ENABLED=true on the MCP server
"""
import json
import os
from pathlib import Path
from typing import Any, AsyncGenerator
import pytest
from mcp import ClientSession
from nextcloud_mcp_server.providers.openai import OpenAIProvider
from tests.conftest import create_mcp_client_session
from tests.integration.sampling_support import create_openai_sampling_callback
# Skip all tests if OpenAI API key not configured
pytestmark = [
pytest.mark.integration,
pytest.mark.skipif(
not os.getenv("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set - skipping OpenAI RAG tests",
),
]
# Ground truth fixture path
FIXTURES_DIR = Path(__file__).parent / "fixtures"
GROUND_TRUTH_FILE = FIXTURES_DIR / "nextcloud_manual_ground_truth.json"
@pytest.fixture(scope="module")
def ground_truth_qa():
"""Load ground truth Q&A pairs for the Nextcloud manual."""
if not GROUND_TRUTH_FILE.exists():
pytest.skip(f"Ground truth file not found: {GROUND_TRUTH_FILE}")
with open(GROUND_TRUTH_FILE) as f:
return json.load(f)
@pytest.fixture(scope="module")
async def openai_provider():
"""OpenAI provider configured from environment (embeddings only)."""
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
embedding_model = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small")
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=embedding_model,
generation_model=None, # Embeddings only
)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def openai_generation_provider():
"""OpenAI provider configured for text generation (for sampling callback)."""
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_BASE_URL")
generation_model = os.getenv("OPENAI_GENERATION_MODEL", "gpt-4o-mini")
# For GitHub Models API, use the prefixed model name
if base_url and "models.github.ai" in base_url:
if not generation_model.startswith("openai/"):
generation_model = f"openai/{generation_model}"
provider = OpenAIProvider(
api_key=api_key,
base_url=base_url,
embedding_model=None, # Generation only
generation_model=generation_model,
)
yield provider
await provider.close()
@pytest.fixture(scope="module")
async def nc_mcp_client_with_sampling(
anyio_backend, openai_generation_provider
) -> AsyncGenerator[ClientSession, Any]:
"""MCP client with OpenAI-based sampling support.
This fixture creates an MCP client that can handle sampling requests
from the server using OpenAI for text generation.
"""
sampling_callback = create_openai_sampling_callback(openai_generation_provider)
async for session in create_mcp_client_session(
url="http://localhost:8000/mcp",
client_name="OpenAI Sampling MCP",
sampling_callback=sampling_callback,
):
yield session
async def test_openai_embeddings_work(openai_provider: OpenAIProvider):
"""Test that OpenAI embeddings can be generated."""
embedding = await openai_provider.embed("test query about Nextcloud")
assert isinstance(embedding, list)
assert len(embedding) > 0
assert all(isinstance(x, float) for x in embedding)
# OpenAI embedding dimensions: 1536 (small) or 3072 (large)
assert len(embedding) in [1536, 3072]
async def test_semantic_search_retrieval(nc_mcp_client, ground_truth_qa):
"""Test that semantic search retrieves relevant documents from the manual.
This tests the retrieval component of RAG - ensuring that queries
return relevant chunks from the indexed Nextcloud User Manual.
"""
# Use first query from ground truth
test_case = ground_truth_qa[0] # 2FA question
query = test_case["query"]
expected_topics = test_case["expected_topics"]
# Perform semantic search via MCP tool
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
},
)
assert result.isError is False, f"Tool call failed: {result}"
data = result.structuredContent
# Verify we got results
assert data["success"] is True
assert data["total_found"] > 0, f"No results for query: {query}"
assert len(data["results"]) > 0
# Check that at least one result contains expected topic keywords
all_excerpts = " ".join([r["excerpt"].lower() for r in data["results"]])
topic_found = any(topic.lower() in all_excerpts for topic in expected_topics)
assert topic_found, (
f"Expected topics {expected_topics} not found in results for query: {query}"
)
async def test_semantic_search_answer_with_sampling(
nc_mcp_client_with_sampling, ground_truth_qa
):
"""Test semantic search with MCP sampling for answer generation.
This tests the full RAG pipeline:
1. Semantic search retrieves relevant documents
2. MCP sampling generates an answer from the retrieved context
3. OpenAI generates the answer via the sampling callback
Uses nc_mcp_client_with_sampling which has OpenAI-based sampling enabled.
"""
# Use the 2FA question - has clear expected answer
test_case = ground_truth_qa[0]
query = test_case["query"]
result = await nc_mcp_client_with_sampling.call_tool(
"nc_semantic_search_answer",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
"max_answer_tokens": 300,
},
)
assert result.isError is False, f"Tool call failed: {result}"
data = result.structuredContent
# Verify response structure
assert data["success"] is True
assert "query" in data
assert "generated_answer" in data
assert "sources" in data
assert "search_method" in data
# Check for either successful sampling or graceful fallback
fallback_methods = {
"semantic_sampling_unsupported",
"semantic_sampling_user_declined",
"semantic_sampling_timeout",
"semantic_sampling_mcp_error",
"semantic_sampling_fallback",
}
if data["search_method"] in fallback_methods:
# Fallback mode - verify sources still returned
assert len(data["sources"]) > 0, "Expected sources even in fallback mode"
pytest.skip(
f"MCP sampling not available (method: {data['search_method']}), "
f"but retrieval succeeded with {len(data['sources'])} sources"
)
else:
# Successful sampling - verify answer quality
assert data["search_method"] == "semantic_sampling"
assert data["generated_answer"] is not None
assert len(data["generated_answer"]) > 50 # Non-trivial answer
# Check answer contains relevant content
answer_lower = data["generated_answer"].lower()
assert any(
keyword in answer_lower
for keyword in ["two-factor", "2fa", "authentication", "password"]
), f"Answer doesn't seem relevant to query: {data['generated_answer'][:200]}"
@pytest.mark.parametrize(
"qa_index,min_expected_results",
[
(0, 1), # 2FA question
(1, 1), # File quotas question
(2, 1), # Linux installation question
(3, 1), # Windows requirements question
(4, 1), # Client apps with 2FA question
],
)
async def test_retrieval_quality_all_queries(
nc_mcp_client, ground_truth_qa, qa_index, min_expected_results
):
"""Test retrieval quality for all ground truth queries.
Validates that each query returns at least the minimum expected
number of relevant results from the Nextcloud manual.
"""
if qa_index >= len(ground_truth_qa):
pytest.skip(f"Ground truth index {qa_index} not available")
test_case = ground_truth_qa[qa_index]
query = test_case["query"]
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": query,
"limit": 5,
"score_threshold": 0.0,
},
)
assert result.isError is False
data = result.structuredContent
assert data["total_found"] >= min_expected_results, (
f"Query '{query}' returned {data['total_found']} results, "
f"expected at least {min_expected_results}"
)
async def test_no_results_for_unrelated_query(nc_mcp_client):
"""Test that completely unrelated queries return low/no scores.
The Nextcloud manual shouldn't have relevant content for
quantum physics queries.
"""
result = await nc_mcp_client.call_tool(
"nc_semantic_search",
arguments={
"query": "quantum entanglement hadron collider particle physics",
"limit": 5,
"score_threshold": 0.5, # Higher threshold to filter irrelevant
},
)
assert result.isError is False
data = result.structuredContent
# Should have few or no high-scoring results
# Low score threshold means we might get some results, but they should be low quality
if data["total_found"] > 0:
# If results exist, they should have low scores
max_score = max(r["score"] for r in data["results"])
assert max_score < 0.8, f"Unexpected high score {max_score} for unrelated query"
-193
View File
@@ -1,193 +0,0 @@
"""Tests for MCP tool annotations (ADR-017)."""
import pytest
from mcp import ClientSession
pytestmark = pytest.mark.integration
async def test_all_tools_have_titles(nc_mcp_client: ClientSession):
"""Verify all tools have human-readable titles (Phase 1 of ADR-017)."""
tools = await nc_mcp_client.list_tools()
# Every tool should have a title (not None)
for tool in tools.tools:
assert tool.title is not None, f"Tool {tool.name} is missing a title"
# Title should not be empty
assert tool.title.strip() != "", f"Tool {tool.name} has an empty title"
# Title should be human-readable (not snake_case function name)
assert tool.title != tool.name, (
f"Tool {tool.name} title is same as function name"
)
async def test_all_tools_have_annotations(nc_mcp_client: ClientSession):
"""Verify all tools have ToolAnnotations (Phase 2 of ADR-017)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
# Every tool should have annotations
assert tool.annotations is not None, f"Tool {tool.name} is missing annotations"
async def test_read_only_tools_have_correct_annotations(nc_mcp_client: ClientSession):
"""Verify read-only tools are marked correctly."""
tools = await nc_mcp_client.list_tools()
# Known read-only tools (list, search, get operations)
read_only_prefixes = ["list", "search", "get"]
read_only_patterns = ["_get_", "_list_", "_search_"]
for tool in tools.tools:
# Check if tool name suggests it's read-only
is_likely_readonly = tool.name.startswith(tuple(read_only_prefixes)) or any(
pattern in tool.name for pattern in read_only_patterns
)
if is_likely_readonly:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.readOnlyHint is True, (
f"Read-only tool {tool.name} should have readOnlyHint=True"
)
assert tool.annotations.destructiveHint is not True, (
f"Read-only tool {tool.name} should not have destructiveHint=True"
)
async def test_destructive_tools_have_correct_annotations(nc_mcp_client: ClientSession):
"""Verify destructive operations are marked correctly."""
tools = await nc_mcp_client.list_tools()
# Known destructive operations
destructive_keywords = ["delete", "remove", "revoke"]
for tool in tools.tools:
has_destructive_keyword = any(
keyword in tool.name.lower() for keyword in destructive_keywords
)
if has_destructive_keyword:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.destructiveHint is True, (
f"Destructive tool {tool.name} should have destructiveHint=True"
)
async def test_delete_operations_are_idempotent(nc_mcp_client: ClientSession):
"""Verify delete operations are marked as idempotent (ADR-017 decision)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "delete" in tool.name.lower():
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is True, (
f"Delete tool {tool.name} should be idempotent (same end state)"
)
async def test_create_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify create operations are marked as non-idempotent."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "create" in tool.name.lower() and "calendar_create_meeting" not in tool.name:
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
assert tool.annotations.idempotentHint is not True, (
f"Create tool {tool.name} should not be idempotent (creates new resources)"
)
async def test_update_operations_not_idempotent(nc_mcp_client: ClientSession):
"""Verify update operations are marked as non-idempotent (due to etag requirements)."""
tools = await nc_mcp_client.list_tools()
for tool in tools.tools:
if "update" in tool.name.lower():
assert tool.annotations is not None, f"Tool {tool.name} missing annotations"
# Most updates use etags which change each time, making them non-idempotent
# Exception: calendar_update_event might be different
assert tool.annotations.idempotentHint is not True, (
f"Update tool {tool.name} should not be idempotent (etag changes)"
)
async def test_webdav_write_is_idempotent(nc_mcp_client: ClientSession):
"""Verify nc_webdav_write_file is marked as idempotent (ADR-017 decision).
WebDAV write uses HTTP PUT without version control, making it idempotent.
Writing same content to same path repeatedly produces same end state.
"""
tools = await nc_mcp_client.list_tools()
write_tool = next(
(tool for tool in tools.tools if tool.name == "nc_webdav_write_file"), None
)
assert write_tool is not None, "nc_webdav_write_file tool not found"
assert write_tool.annotations is not None, "write_file missing annotations"
assert write_tool.annotations.idempotentHint is True, (
"nc_webdav_write_file should be idempotent (HTTP PUT without version control)"
)
async def test_semantic_search_open_world(nc_mcp_client: ClientSession):
"""Verify semantic search has openWorldHint=True (ADR-017 decision).
Semantic search queries external Nextcloud service, consistent with other tools.
"""
tools = await nc_mcp_client.list_tools()
semantic_tool = next(
(tool for tool in tools.tools if tool.name == "nc_semantic_search"), None
)
if semantic_tool: # Only if semantic search is enabled
assert semantic_tool.annotations is not None, (
"semantic_search missing annotations"
)
assert semantic_tool.annotations.openWorldHint is True, (
"nc_semantic_search should have openWorldHint=True (queries external service)"
)
async def test_annotation_consistency(nc_mcp_client: ClientSession):
"""Verify annotation consistency across similar tools."""
tools = await nc_mcp_client.list_tools()
# Group tools by category
categories = {
"notes": [],
"calendar": [],
"contacts": [],
"webdav": [],
"tables": [],
"deck": [],
"cookbook": [],
"sharing": [],
}
for tool in tools.tools:
for category in categories:
if tool.name.startswith(f"nc_{category}_"):
categories[category].append(tool)
# Within each category, similar operations should have similar annotations
for category, category_tools in categories.items():
# All list/search/get operations should be read-only
read_ops = [
t
for t in category_tools
if any(op in t.name for op in ["list", "search", "get"])
]
for tool in read_ops:
assert tool.annotations.readOnlyHint is True, (
f"{tool.name} is a read operation but not marked read-only"
)
# All delete operations should be destructive and idempotent
delete_ops = [t for t in category_tools if "delete" in t.name]
for tool in delete_ops:
assert tool.annotations.destructiveHint is True, (
f"{tool.name} is a delete operation but not marked destructive"
)
assert tool.annotations.idempotentHint is True, (
f"{tool.name} is a delete operation but not marked idempotent"
)
-174
View File
@@ -1,174 +0,0 @@
"""
Test that DNS rebinding protection is properly disabled for containerized deployments.
This test verifies that the fix for MCP 1.23.x DNS rebinding protection works correctly.
Without the fix, requests with Host headers that don't match the default allowed list
(127.0.0.1:*, localhost:*, [::1]:*) would be rejected with a 421 Misdirected Request error.
"""
import httpx
import pytest
@pytest.mark.integration
async def test_accepts_various_host_headers():
"""Test that the MCP server accepts requests with various Host headers.
This test simulates what happens in containerized deployments where the Host
header might be a k8s service DNS name, a proxied hostname, or other values
that don't match the default allowed list.
Without the DNS rebinding protection fix, these requests would fail with:
- 421 Misdirected Request (for Host header mismatch)
- 403 Forbidden (for Origin header mismatch)
"""
mcp_url = "http://localhost:8000/mcp"
# Test various Host headers that would be rejected by DNS rebinding protection
test_cases = [
{
"name": "Kubernetes service DNS",
"headers": {
"Host": "nextcloud-mcp-server.default.svc.cluster.local:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Custom domain",
"headers": {
"Host": "mcp.example.com:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Proxied hostname",
"headers": {
"Host": "proxy.internal:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
{
"name": "Default localhost (should always work)",
"headers": {
"Host": "localhost:8000",
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
},
]
# Create a simple initialize request payload
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
for test_case in test_cases:
print(f"\n🧪 Testing: {test_case['name']}")
print(f" Host header: {test_case['headers']['Host']}")
response = await client.post(
mcp_url,
json=initialize_request,
headers=test_case["headers"],
timeout=10.0,
)
# With DNS rebinding protection enabled (MCP 1.23 default), these would fail with:
# - 421 Misdirected Request (Host header not in allowed list)
# - 403 Forbidden (Origin header not in allowed list)
#
# With our fix (enable_dns_rebinding_protection=False), they should succeed
assert response.status_code in [200, 202], (
f"Request failed for {test_case['name']}: "
f"status={response.status_code}, "
f"headers={test_case['headers']}, "
f"body={response.text[:200]}"
)
print(f" ✅ Status: {response.status_code}")
# For SSE responses (status 200), verify we got SSE format
# For JSON responses (status 202), verify we got valid JSON
if response.status_code == 200:
# SSE response - should start with "event: message" or similar
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format for {test_case['name']}, got: {response_text[:200]}"
)
print(" ✅ Received SSE stream response")
elif response.status_code == 202:
# JSON response for notifications
response_json = response.json()
assert "jsonrpc" in response_json or response_json is None, (
f"Invalid response for {test_case['name']}: {response_json}"
)
print(" ✅ Received JSON response")
@pytest.mark.integration
async def test_dns_rebinding_protection_is_disabled():
"""Verify that DNS rebinding protection is actually disabled in the configuration.
This test makes a request that would DEFINITELY fail if DNS rebinding protection
was enabled with default settings (only allowing 127.0.0.1:*, localhost:*, [::1]:*).
"""
mcp_url = "http://localhost:8000/mcp"
# Use a Host header that would NEVER be in the default allowed list
malicious_host = "evil.attacker.com:8000"
initialize_request = {
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0.0"},
},
"id": 1,
}
async with httpx.AsyncClient() as client:
response = await client.post(
mcp_url,
json=initialize_request,
headers={
"Host": malicious_host,
"Content-Type": "application/json",
"Accept": "application/json, text/event-stream",
},
timeout=10.0,
)
# If DNS rebinding protection was enabled, this would return:
# - 421 Misdirected Request (Host header validation failed)
#
# Since we disabled it, this should succeed (status 200 or 202)
assert response.status_code in [200, 202], (
f"DNS rebinding protection may still be enabled! "
f"Request with Host='{malicious_host}' was rejected: "
f"status={response.status_code}, body={response.text[:500]}"
)
# Verify we got a valid response (SSE or JSON)
if response.status_code == 200:
response_text = response.text
assert "event:" in response_text or "data:" in response_text, (
f"Expected SSE format, got: {response_text[:200]}"
)
print("✅ DNS rebinding protection is properly disabled")
print(
f" Request with Host '{malicious_host}' succeeded: {response.status_code}"
)
-232
View File
@@ -117,235 +117,3 @@ def test_parse_search_response_with_empty_tags(mocker):
assert len(results) == 1
assert "tags" in results[0]
assert results[0]["tags"] == []
@pytest.mark.unit
async def test_get_file_info_returns_file_details(mocker):
"""Test that get_file_info returns file info including file ID."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock PROPFIND response
mock_response = AsyncMock()
mock_response.status_code = 207
mock_response.content = b"""<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:oc="http://owncloud.org/ns">
<d:response>
<d:href>/remote.php/dav/files/testuser/Documents/test.pdf</d:href>
<d:propstat>
<d:prop>
<oc:fileid>12345</oc:fileid>
<d:displayname>test.pdf</d:displayname>
<d:getcontentlength>1024</d:getcontentlength>
<d:getcontenttype>application/pdf</d:getcontenttype>
<d:getlastmodified>Sat, 01 Jan 2025 00:00:00 GMT</d:getlastmodified>
<d:getetag>"abc123"</d:getetag>
<d:resourcetype/>
</d:prop>
</d:propstat>
</d:response>
</d:multistatus>"""
mock_response.raise_for_status = mocker.Mock()
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call get_file_info
result = await client.get_file_info("Documents/test.pdf")
# Verify result
assert result is not None
assert result["id"] == 12345
assert result["name"] == "test.pdf"
assert result["path"] == "Documents/test.pdf"
assert result["content_type"] == "application/pdf"
assert result["size"] == 1024
assert result["etag"] == "abc123"
assert result["is_directory"] is False
@pytest.mark.unit
async def test_get_file_info_returns_none_for_missing_file(mocker):
"""Test that get_file_info returns None for missing files."""
from httpx import HTTPStatusError, Response
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 response
mock_response = mocker.Mock(spec=Response)
mock_response.status_code = 404
mock_http_client.request = AsyncMock(
side_effect=HTTPStatusError(
"Not Found", request=mocker.Mock(), response=mock_response
)
)
# Call get_file_info
result = await client.get_file_info("nonexistent.pdf")
# Verify result is None
assert result is None
@pytest.mark.unit
async def test_create_tag_creates_system_tag(mocker):
"""Test that create_tag creates a system tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock WebDAV response with Content-Location header
mock_response = AsyncMock()
mock_response.status_code = 201
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
mock_response.raise_for_status = mocker.Mock()
mock_http_client.post = AsyncMock(return_value=mock_response)
# Call create_tag
result = await client.create_tag("vector-index")
# Verify result
assert result["id"] == 42
assert result["name"] == "vector-index"
assert result["userVisible"] is True
assert result["userAssignable"] is True
# Verify API call
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert call_args[0][0] == "/remote.php/dav/systemtags/"
assert call_args[1]["json"]["name"] == "vector-index"
assert call_args[1]["json"]["userVisible"] is True
assert call_args[1]["json"]["userAssignable"] is True
@pytest.mark.unit
async def test_get_or_create_tag_returns_existing_tag(mocker):
"""Test that get_or_create_tag returns existing tag without creating."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock existing tag
mocker.patch.object(
client,
"get_tag_by_name",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
mock_create = mocker.patch.object(client, "create_tag")
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify existing tag returned without creating
assert result["id"] == 42
mock_create.assert_not_called()
@pytest.mark.unit
async def test_get_or_create_tag_creates_new_tag(mocker):
"""Test that get_or_create_tag creates tag when not found."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock no existing tag
mocker.patch.object(client, "get_tag_by_name", return_value=None)
mocker.patch.object(
client,
"create_tag",
return_value={"id": 42, "name": "vector-index", "userVisible": True},
)
# Call get_or_create_tag
result = await client.get_or_create_tag("vector-index")
# Verify tag was created
assert result["id"] == 42
client.create_tag.assert_called_once_with("vector-index", True, True)
@pytest.mark.unit
async def test_assign_tag_to_file_success(mocker):
"""Test that assign_tag_to_file assigns tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 201 Created response
mock_response = AsyncMock()
mock_response.status_code = 201
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "PUT"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_assign_tag_to_file_already_assigned(mocker):
"""Test that assign_tag_to_file handles already assigned (409) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 409 Conflict response (already assigned)
mock_response = AsyncMock()
mock_response.status_code = 409
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call assign_tag_to_file
result = await client.assign_tag_to_file(12345, 42)
# Verify result (should succeed even with 409)
assert result is True
@pytest.mark.unit
async def test_remove_tag_from_file_success(mocker):
"""Test that remove_tag_from_file removes tag via WebDAV."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 204 No Content response
mock_response = AsyncMock()
mock_response.status_code = 204
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result
assert result is True
# Verify API call
mock_http_client.request.assert_called_once()
call_args = mock_http_client.request.call_args
assert call_args[0][0] == "DELETE"
assert "/systemtags-relations/files/12345/42" in call_args[0][1]
@pytest.mark.unit
async def test_remove_tag_from_file_not_assigned(mocker):
"""Test that remove_tag_from_file handles not assigned (404) gracefully."""
mock_http_client = AsyncMock()
client = WebDAVClient(mock_http_client, "testuser")
# Mock 404 Not Found response (tag wasn't assigned)
mock_response = AsyncMock()
mock_response.status_code = 404
mock_http_client.request = AsyncMock(return_value=mock_response)
# Call remove_tag_from_file
result = await client.remove_tag_from_file(12345, 42)
# Verify result (should succeed even with 404)
assert result is True
Generated
+517 -592
View File
File diff suppressed because it is too large Load Diff