Compare commits
115 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54fdc8addc | |||
| e0320e761c | |||
| 2b7c308188 | |||
| 40ac52654f | |||
| 034e405824 | |||
| 20404cf3f2 | |||
| 264bb5475c | |||
| 6e3f9f6e79 | |||
| 9d0a993c2a | |||
| cd3e60ba4f | |||
| 360299f5f6 | |||
| d61e33113c | |||
| 5faf7cf45f | |||
| cd922fa750 | |||
| a4d4c386f7 | |||
| c8da826ef7 | |||
| 5166c2c4d7 | |||
| ec70e70a5d | |||
| 4a79b37714 | |||
| 76ae1c3603 | |||
| a60b88b80e | |||
| e31b4433a1 | |||
| 19183ad14a | |||
| e1412320a7 | |||
| b9c94dfab0 | |||
| 6f43c09bd0 | |||
| 9e15e95c2b | |||
| 1306c4cc9c | |||
| f1247817d3 | |||
| fdad5b85c9 | |||
| 39ee0b5973 | |||
| 33675c8ae8 | |||
| 90d5e9887a | |||
| c3af591810 | |||
| bb8a6200aa | |||
| 44573366eb | |||
| edb0af2bda | |||
| 7d5bb54b64 | |||
| a18c63792a | |||
| 0b58707a49 | |||
| 0561b55af5 | |||
| d785ed9054 | |||
| 88fb8417fd | |||
| f70d743c8b | |||
| 251b8a10c0 | |||
| 3f06e2ee77 | |||
| 7f11c793ef | |||
| e28dcbff9a | |||
| 89ec0186a4 | |||
| 6e1efde8c6 | |||
| 6aa80d4210 | |||
| 4e86006b3f | |||
| 679e22a7c2 | |||
| 4d3228a4a8 | |||
| 0aa307f0b6 | |||
| 6a69ecefb1 | |||
| c05beb66e9 | |||
| 34ddb24014 | |||
| 9d69613df7 | |||
| 630f818538 | |||
| b280a720ff | |||
| 48bac9c212 | |||
| e88c49fb50 | |||
| 9e10a5a400 | |||
| 1dbea24fa2 | |||
| 0606228b40 | |||
| f35b9f0988 | |||
| c400c46672 | |||
| fbdeb2161d | |||
| 8c7d03dd29 | |||
| 135ce7b2df | |||
| 0e47ae051b | |||
| 04255473d2 | |||
| ce6bbff389 | |||
| 92c4bf36f6 | |||
| 0bedbf1877 | |||
| a5cb6e1242 | |||
| a33f6a2f15 | |||
| d79e9090e6 | |||
| 97fd660e38 | |||
| 96e168d035 | |||
| 4d2b77ecaf | |||
| e48da80a4b | |||
| 6125312f61 | |||
| 007fd0c2e3 | |||
| c4f90d6a57 | |||
| 5dd62c9466 | |||
| 4d072d7217 | |||
| b4242b1394 | |||
| fa2343dff9 | |||
| 1b1667bc2b | |||
| c2b4bf9c67 | |||
| 0845fefe6c | |||
| d911556a84 | |||
| 38be8d9401 | |||
| 9f3190f62a | |||
| 41aeb7e0f2 | |||
| f8e67519e1 | |||
| 4279dcba1e | |||
| be7e3d6b56 | |||
| 41e128190b | |||
| ba869ccde5 | |||
| 27fe066b23 | |||
| e94b8ff714 | |||
| e3a6894904 | |||
| 92b97bda00 | |||
| d5c6039296 | |||
| 3fa13c8bfd | |||
| 9d306b71fa | |||
| 38a936c120 | |||
| 86d13a7240 | |||
| 0b2d449ffa | |||
| d881373dce | |||
| 9ade4c65f3 | |||
| 5c73b85f65 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
@@ -25,7 +25,7 @@ jobs:
|
|||||||
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
github_token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
|
||||||
changelog_increment_filename: body.md
|
changelog_increment_filename: body.md
|
||||||
- name: Release
|
- name: Release
|
||||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0
|
||||||
with:
|
with:
|
||||||
body_path: "body.md"
|
body_path: "body.md"
|
||||||
tag_name: v${{ env.REVISION }}
|
tag_name: v${{ env.REVISION }}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@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:*)"'
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@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:*)'
|
||||||
|
|
||||||
@@ -12,11 +12,11 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
# list of Docker images to use as base name for tags
|
# list of Docker images to use as base name for tags
|
||||||
images: |
|
images: |
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -24,39 +24,25 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
|
||||||
submodules: 'true'
|
|
||||||
|
|
||||||
###### 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
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: |
|
||||||
|
./docker-compose.yml
|
||||||
|
./docker-compose.ci.yml
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
env:
|
env:
|
||||||
# Override MCP container environment for OpenAI + vector sync
|
# Environment variables passed to docker-compose.ci.yml
|
||||||
VECTOR_SYNC_ENABLED: "true"
|
|
||||||
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
|
||||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
OPENAI_BASE_URL: "https://models.github.ai/inference"
|
||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
|
VECTOR_SYNC_SCAN_INTERVAL: "5"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Wait for Nextcloud to be ready
|
- name: Wait for Nextcloud to be ready
|
||||||
run: |
|
run: |
|
||||||
@@ -101,11 +87,17 @@ jobs:
|
|||||||
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
OPENAI_EMBEDDING_MODEL: ${{ inputs.embedding_model }}
|
||||||
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
OPENAI_GENERATION_MODEL: ${{ inputs.generation_model }}
|
||||||
run: |
|
run: |
|
||||||
uv run pytest tests/integration/test_rag_openai.py -v --log-cli-level=INFO
|
uv run pytest tests/integration/test_rag.py -v --log-cli-level=INFO --provider openai
|
||||||
|
|
||||||
|
- name: Capture MCP container logs
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "=== MCP Container Logs ==="
|
||||||
|
docker compose logs mcp --tail=500
|
||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
||||||
with:
|
with:
|
||||||
name: rag-evaluation-results
|
name: rag-evaluation-results
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
run: uv python install 3.11
|
run: uv python install 3.11
|
||||||
- name: Build
|
- name: Build
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
|
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||||
with:
|
with:
|
||||||
submodules: 'true'
|
submodules: 'true'
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ jobs:
|
|||||||
###### Required to build OIDC App ######
|
###### Required to build OIDC App ######
|
||||||
|
|
||||||
- name: Set up php 8.4
|
- name: Set up php 8.4
|
||||||
uses: shivammathur/setup-php@bf6b4fbd49ca58e4608c9c89fba0b8d90bd2a39f # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
@@ -49,14 +49,14 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1
|
uses: hoverkraft-tech/compose-action@248470ecc5ed40d8ed3d4480d8260d77179ef579 # v2.4.2
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
#compose-flags: "--profile qdrant"
|
#compose-flags: "--profile qdrant"
|
||||||
up-flags: "--build"
|
up-flags: "--build"
|
||||||
|
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@1e862dfacbd1d6d858c55d9b792c756523627244 # v7.1.4
|
uses: astral-sh/setup-uv@ed21f2f24f8dd64503750218de024bcf64c7250a # v7.1.5
|
||||||
|
|
||||||
- name: Install Playwright dependencies
|
- name: Install Playwright dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,3 +1,92 @@
|
|||||||
|
## 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)
|
## v0.48.2 (2025-11-23)
|
||||||
|
|
||||||
### Fix
|
### Fix
|
||||||
|
|||||||
@@ -56,6 +56,68 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
- Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false)
|
||||||
- Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true)
|
- 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
|
### Project Structure
|
||||||
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
- `nextcloud_mcp_server/client/` - HTTP clients for Nextcloud APIs
|
||||||
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
- `nextcloud_mcp_server/server/` - MCP tool/resource definitions
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+2
-2
@@ -12,12 +12,12 @@
|
|||||||
# - Per-session app password authentication
|
# - Per-session app password authentication
|
||||||
# - Multi-user support via Smithery session config
|
# - Multi-user support via Smithery session config
|
||||||
|
|
||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:b43ff04d5df04ad5cabb80890b7ef74e8410e3395b19af970dcd52d7a4bff921
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:fa48eefe2146644c2308b909d6bb7651a768178f84fc9550dcd495e4d6d84d01
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.9.11@sha256:5aa820129de0a600924f166aec9cb51613b15b68f1dcd2a02f31a500d2ede568 /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.9.17@sha256:5cb6b54d2bc3fe2eb9a8483db958a0b9eebf9edff68adedb369df8e7b98711a2 /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ http://127.0.0.1:8000/mcp
|
|||||||
|
|
||||||
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
- **90+ MCP Tools** - Comprehensive API coverage across 8 Nextcloud apps
|
||||||
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
- **MCP Resources** - Structured data URIs for browsing Nextcloud data
|
||||||
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes (requires Qdrant + Ollama)
|
- **Semantic Search (Experimental)** - Optional vector-powered search for Notes, Files, News items, and Deck cards (requires Qdrant + Ollama)
|
||||||
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
- **Document Processing** - OCR and text extraction from PDFs, DOCX, images with progress notifications
|
||||||
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
- **Flexible Deployment** - Docker, Kubernetes (Helm), VM, or local installation
|
||||||
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
- **Production-Ready Auth** - Basic Auth with app passwords (recommended) or OAuth2/OIDC (experimental)
|
||||||
@@ -81,7 +81,7 @@ http://127.0.0.1:8000/mcp
|
|||||||
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
| **Cookbook** | 13 | Recipe management, URL import (schema.org) |
|
||||||
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
| **Tables** | 5 | Row operations on Nextcloud Tables |
|
||||||
| **Sharing** | 10+ | Create and manage shares |
|
| **Sharing** | 10+ | Create and manage shares |
|
||||||
| **Semantic Search** | 2+ | Vector search for Notes (experimental, opt-in, requires infrastructure) |
|
| **Semantic Search** | 2+ | Vector search for Notes, Files, News items, and Deck cards (experimental, opt-in, requires infrastructure) |
|
||||||
|
|
||||||
Want to see another Nextcloud app supported? [Open an issue](https://github.com/cbcoutinho/nextcloud-mcp-server/issues) or contribute a pull request!
|
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 +145,7 @@ This enables natural language queries and helps discover related content across
|
|||||||
### Features
|
### Features
|
||||||
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
- **[App Documentation](docs/)** - Notes, Calendar, Contacts, WebDAV, Deck, Cookbook, Tables
|
||||||
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
- **[Document Processing](docs/configuration.md#document-processing)** - OCR and text extraction setup
|
||||||
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes only, opt-in)
|
- **[Semantic Search Architecture](docs/semantic-search-architecture.md)** - Experimental vector search (Notes, Files, News items, Deck cards; opt-in)
|
||||||
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
- **[Vector Sync UI Guide](docs/user-guide/vector-sync-ui.md)** - Browser interface for semantic search visualization and testing
|
||||||
|
|
||||||
### Advanced Topics
|
### Advanced Topics
|
||||||
|
|||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euox pipefail
|
||||||
|
|
||||||
|
php /var/www/html/occ app:enable news
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
version: 1.16.0
|
version: 1.16.2
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.35.0
|
version: 1.35.0
|
||||||
digest: sha256:da8db198b12ce0252df220fabb297cfe69186edb8e67952c52e05de778189b92
|
digest: sha256:bcb0779739e4710b90bb65f6a7baeaa295bd0ba9776f8a1cf8d9b69d233c8ec0
|
||||||
generated: "2025-11-21T11:09:07.997781541Z"
|
generated: "2025-12-05T11:11:27.999374001Z"
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: nextcloud-mcp-server
|
name: nextcloud-mcp-server
|
||||||
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
|
||||||
type: application
|
type: application
|
||||||
version: 0.48.2
|
version: 0.52.0
|
||||||
appVersion: "0.48.2"
|
appVersion: "0.52.0"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,7 +27,7 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.0"
|
version: "1.16.2"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# CI-specific overrides for RAG evaluation pipeline
|
||||||
|
# This file is used by the rag-evaluation.yml workflow to configure the MCP
|
||||||
|
# container with OpenAI/GitHub Models API for vector embeddings.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker compose -f docker-compose.yml -f docker-compose.ci.yml up
|
||||||
|
#
|
||||||
|
# Environment variables (set in CI workflow):
|
||||||
|
# OPENAI_API_KEY - API key for embeddings (GitHub Models uses GITHUB_TOKEN)
|
||||||
|
# OPENAI_BASE_URL - API endpoint (e.g., https://models.github.ai/inference)
|
||||||
|
# OPENAI_EMBEDDING_MODEL - Model name (e.g., openai/text-embedding-3-small)
|
||||||
|
# OPENAI_GENERATION_MODEL - Model name for generation (e.g., openai/gpt-4o-mini)
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp:
|
||||||
|
environment:
|
||||||
|
# OpenAI provider configuration (required for CI vector sync)
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://models.github.ai/inference}
|
||||||
|
- OPENAI_EMBEDDING_MODEL=${OPENAI_EMBEDDING_MODEL:-openai/text-embedding-3-small}
|
||||||
|
- OPENAI_GENERATION_MODEL=${OPENAI_GENERATION_MODEL:-openai/gpt-4o-mini}
|
||||||
|
# Faster sync for CI
|
||||||
|
- VECTOR_SYNC_SCAN_INTERVAL=${VECTOR_SYNC_SCAN_INTERVAL:-5}
|
||||||
|
# Enable document processing for PDF parsing
|
||||||
|
- ENABLE_DOCUMENT_PROCESSING=true
|
||||||
+5
-5
@@ -21,7 +21,7 @@ services:
|
|||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: docker.io/library/nextcloud:32.0.2@sha256:ac08482d73ffd85d94069ba291bbd5fb39a70ff21502030a2e3e2d89a7246a48
|
image: docker.io/library/nextcloud:32.0.3@sha256:54993ed39dc77f7a6ade142b1625972cb7a9393074325373402d47231314afbb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 0.0.0.0:8080:80
|
- 0.0.0.0:8080:80
|
||||||
@@ -34,7 +34,7 @@ services:
|
|||||||
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
- ./app-hooks:/docker-entrypoint-hooks.d:ro
|
||||||
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
|
# 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
|
# The post-installation hook will register /opt/apps as an additional app directory
|
||||||
- ./third_party:/opt/apps:ro
|
#- ./third_party:/opt/apps:ro
|
||||||
environment:
|
environment:
|
||||||
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
- NEXTCLOUD_TRUSTED_DOMAINS=app
|
||||||
- NEXTCLOUD_ADMIN_USER=admin
|
- NEXTCLOUD_ADMIN_USER=admin
|
||||||
@@ -51,7 +51,7 @@ services:
|
|||||||
retries: 30
|
retries: 30
|
||||||
|
|
||||||
recipes:
|
recipes:
|
||||||
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
|
image: docker.io/library/nginx:alpine@sha256:289decab414250121a93c3f1b8316b9c69906de3a4993757c424cb964169ad42
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
- ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro
|
||||||
@@ -158,7 +158,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.5@sha256:653852bfdea2be6e958b9e90a976eff1c6de34edd55f2f679bdc48ef16bc528e
|
image: quay.io/keycloak/keycloak:26.4.7@sha256:9409c59bdfb65dbffa20b11e6f18b8abb9281d480c7ca402f51ed3d5977e6007
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -245,7 +245,7 @@ services:
|
|||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: qdrant/qdrant:v1.16.0@sha256:1005201498cf927d835383d0f918b17d8c9da7db58550f169f694455e42d78f4
|
image: qdrant/qdrant:v1.16.2@sha256:dab6de32f7b2cc599985a7c764db3e8b062f70508fb85ca074aa856f829bf335
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
@@ -0,0 +1,506 @@
|
|||||||
|
# ADR-017: Add MCP Tool Annotations for Enhanced Client UX
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Implemented
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
The MCP Python SDK supports tool annotations that provide behavioral hints and improved UX to MCP clients. Currently, our 101 tools across 10 modules lack these annotations, resulting in:
|
||||||
|
|
||||||
|
- Snake_case function names displayed to users (e.g., "nc_notes_create_note" instead of "Create Note")
|
||||||
|
- No behavioral hints for clients about read-only, destructive, or idempotent operations
|
||||||
|
- Missing parameter descriptions for better auto-completion and inline help
|
||||||
|
- Clients cannot optimize caching, warn before destructive operations, or retry safely
|
||||||
|
|
||||||
|
### Available MCP Annotations
|
||||||
|
|
||||||
|
The MCP SDK provides three types of annotations:
|
||||||
|
|
||||||
|
#### 1. Tool Decorator Parameters
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Human-Readable Name",
|
||||||
|
description="Tool description", # Can also come from docstring
|
||||||
|
annotations=ToolAnnotations(...),
|
||||||
|
icons=[Icon(...)] # Optional visual icons
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. ToolAnnotations Behavioral Hints
|
||||||
|
```python
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
|
ToolAnnotations(
|
||||||
|
title="Alternative Title", # Decorator title takes precedence
|
||||||
|
readOnlyHint=True, # Tool doesn't modify data
|
||||||
|
destructiveHint=True, # Tool may delete/overwrite data
|
||||||
|
idempotentHint=True, # Repeated calls with same args are safe
|
||||||
|
openWorldHint=True # Interacts with external entities
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Parameter Descriptions
|
||||||
|
```python
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
async def tool(
|
||||||
|
param: str = Field(description="What this parameter does"),
|
||||||
|
ctx: Context
|
||||||
|
):
|
||||||
|
```
|
||||||
|
|
||||||
|
### Idempotency Analysis
|
||||||
|
|
||||||
|
**Important**: Idempotency means calling with **the same inputs** produces the same result.
|
||||||
|
|
||||||
|
**NOT Idempotent** (different inputs each call):
|
||||||
|
- **Updates with etag**: `update_note(id=1, title="X", etag="abc")` → etag changes to "def"
|
||||||
|
- Second call: `update_note(id=1, title="X", etag="abc")` → fails (etag mismatch)
|
||||||
|
- Different input (stale etag) → different result (error)
|
||||||
|
- **Creates**: `create_note(title="X")` → creates note 1
|
||||||
|
- Second call → creates note 2 (different result)
|
||||||
|
- **Append operations**: `append_content(id=1, text="X")` → adds X once
|
||||||
|
- Second call → adds X again (different result)
|
||||||
|
|
||||||
|
**Idempotent**:
|
||||||
|
- **Deletes**: `delete_note(id=1)` → note deleted
|
||||||
|
- Second call → 404 or success (same end state: note doesn't exist)
|
||||||
|
- Note: May return different status code, but end state is identical
|
||||||
|
- **Full resource PUT without version control**: `write_file(path="/test.txt", content="Hello")` → file has "Hello"
|
||||||
|
- Second call → file still has "Hello" (same end state)
|
||||||
|
- Example: `nc_webdav_write_file` uses HTTP PUT without etags/version control
|
||||||
|
- **Set operations**: `set_property(id=1, value="X")` → property = X
|
||||||
|
- Second call → property still = X (same result)
|
||||||
|
- Note: Nextcloud updates with etags use version control, so not idempotent
|
||||||
|
|
||||||
|
**Read-Only** (always idempotent, never destructive):
|
||||||
|
- All list, search, get operations
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Add annotations to all 101 tools in three phases:
|
||||||
|
|
||||||
|
### Phase 1: Titles (Quick Win)
|
||||||
|
Add human-readable titles to all tools:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(title="Create Note")
|
||||||
|
async def nc_notes_create_note(...):
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 2-3 hours
|
||||||
|
**Impact**: Immediate UX improvement
|
||||||
|
|
||||||
|
### Phase 2: ToolAnnotations (Behavioral Hints)
|
||||||
|
Add annotations based on corrected categorization:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Read-only tools
|
||||||
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True # Nextcloud is external to MCP server
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete tools (idempotent: same end state)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True,
|
||||||
|
idempotentHint=True, # Deleting deleted item = same end state
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tools (not idempotent: creates multiple items)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Create Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update tools with etag (not idempotent: etag changes)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Etag required = different inputs each time
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Append operations (not idempotent: adds content each time)
|
||||||
|
@mcp.tool(
|
||||||
|
title="Append to Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False,
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 4-6 hours
|
||||||
|
**Impact**: Better client behavior (caching, warnings, retry logic)
|
||||||
|
|
||||||
|
### Phase 3: Parameter Descriptions
|
||||||
|
Add Field() descriptions to parameters:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
@mcp.tool(title="Create Note", annotations=ToolAnnotations(idempotentHint=False))
|
||||||
|
async def nc_notes_create_note(
|
||||||
|
title: str = Field(description="The title of the note"),
|
||||||
|
content: str = Field(description="Markdown content of the note"),
|
||||||
|
category: str = Field(description="Category or folder name for organizing"),
|
||||||
|
ctx: Context
|
||||||
|
) -> CreateNoteResponse:
|
||||||
|
```
|
||||||
|
|
||||||
|
**Effort**: 6-8 hours
|
||||||
|
**Impact**: Better auto-completion and inline help
|
||||||
|
|
||||||
|
## Tool Categorization
|
||||||
|
|
||||||
|
### Read-Only Tools (~40 tools)
|
||||||
|
**Pattern**: List, search, get operations
|
||||||
|
**Annotations**: `readOnlyHint=True`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_search_notes` → "Search Notes"
|
||||||
|
- `nc_webdav_list_directory` → "List Files and Directories"
|
||||||
|
- `nc_calendar_list_calendars` → "List Calendars"
|
||||||
|
- `nc_contacts_get_contact` → "Get Contact"
|
||||||
|
- `nc_semantic_search` → "Semantic Search"
|
||||||
|
- `check_logged_in` → "Check Server Login Status"
|
||||||
|
|
||||||
|
### Create Tools (~20 tools)
|
||||||
|
**Pattern**: Create new resources
|
||||||
|
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_create_note` → "Create Note"
|
||||||
|
- `nc_calendar_create_event` → "Create Calendar Event"
|
||||||
|
- `nc_contacts_create_contact` → "Create Contact"
|
||||||
|
- `deck_create_card` → "Create Kanban Card"
|
||||||
|
- `nc_tables_create_row` → "Create Table Row"
|
||||||
|
|
||||||
|
### Update Tools (~25 tools)
|
||||||
|
**Pattern**: Modify existing resources with etag
|
||||||
|
**Annotations**: `idempotentHint=False` (etag changes), `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_update_note` → "Update Note"
|
||||||
|
- `nc_calendar_update_event` → "Update Calendar Event"
|
||||||
|
- `nc_contacts_update_contact` → "Update Contact"
|
||||||
|
- `deck_update_card` → "Update Kanban Card"
|
||||||
|
|
||||||
|
**Rationale**: Updates require etag, which changes after each update. Same parameters on second call will fail due to stale etag = NOT idempotent.
|
||||||
|
|
||||||
|
### Append/Accumulate Tools (~5 tools)
|
||||||
|
**Pattern**: Add content without replacing
|
||||||
|
**Annotations**: `idempotentHint=False`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_append_content` → "Append to Note"
|
||||||
|
|
||||||
|
**Rationale**: Each call adds content, changing the result = NOT idempotent.
|
||||||
|
|
||||||
|
### Delete Tools (~10 tools)
|
||||||
|
**Pattern**: Remove resources
|
||||||
|
**Annotations**: `destructiveHint=True`, `idempotentHint=True`, `openWorldHint=True`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `nc_notes_delete_note` → "Delete Note"
|
||||||
|
- `nc_webdav_delete_resource` → "Delete File or Directory"
|
||||||
|
- `nc_calendar_delete_event` → "Delete Calendar Event"
|
||||||
|
- `nc_contacts_delete_contact` → "Delete Contact"
|
||||||
|
|
||||||
|
**Rationale**: Deleting already-deleted item results in same end state (item doesn't exist) = idempotent. Status code may differ, but outcome is identical.
|
||||||
|
|
||||||
|
### Special Cases
|
||||||
|
|
||||||
|
#### OAuth Provisioning Tools
|
||||||
|
```python
|
||||||
|
# Not read-only but requires user interaction
|
||||||
|
@mcp.tool(
|
||||||
|
title="Grant Server Access to Nextcloud",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=False,
|
||||||
|
idempotentHint=False, # Creates new OAuth session each time
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def provision_nextcloud_access(ctx: Context):
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Semantic Search (Closed World)
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Semantic Search",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=False # Searches only indexed Nextcloud data
|
||||||
|
)
|
||||||
|
)
|
||||||
|
async def nc_semantic_search(query: str, ctx: Context):
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**: Semantic search only queries pre-indexed Nextcloud content, not the "open world" like web search would.
|
||||||
|
|
||||||
|
## Tool Priority Matrix
|
||||||
|
|
||||||
|
### Critical Priority (~2 tools)
|
||||||
|
OAuth tools required for server functionality:
|
||||||
|
- `provision_nextcloud_access` → "Grant Server Access to Nextcloud"
|
||||||
|
- `check_logged_in` → "Check Server Login Status"
|
||||||
|
|
||||||
|
### High Priority (~50 tools)
|
||||||
|
Most commonly used modules:
|
||||||
|
- **Notes** (14 tools): Create, read, update, delete notes
|
||||||
|
- **WebDAV** (13 tools): File operations
|
||||||
|
- **Calendar** (15 tools): Events and todos
|
||||||
|
- **Semantic Search** (6 tools): AI-powered search
|
||||||
|
- **Contacts** (9 tools): Address book operations
|
||||||
|
|
||||||
|
### Medium Priority (~35 tools)
|
||||||
|
Secondary functionality:
|
||||||
|
- **Deck** (9 tools): Kanban boards
|
||||||
|
- **Tables** (7 tools): Structured data
|
||||||
|
- **Sharing** (5 tools): File sharing
|
||||||
|
|
||||||
|
### Low Priority (~14 tools)
|
||||||
|
Less frequently used:
|
||||||
|
- **Cookbook** (8 tools): Recipe management
|
||||||
|
- **News** (6 tools): RSS feeds
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Week 1: Phase 1 - Titles
|
||||||
|
- Add human-readable titles to all 101 tools
|
||||||
|
- Update tool name mapping in documentation
|
||||||
|
- Manual test in MCP inspector
|
||||||
|
|
||||||
|
### Week 2: Phase 2 - ToolAnnotations (High Priority)
|
||||||
|
- Add annotations to Critical and High priority tools (~52 tools)
|
||||||
|
- Focus on Notes, WebDAV, Calendar, Semantic, OAuth
|
||||||
|
- Add unit tests validating annotation presence
|
||||||
|
|
||||||
|
### Week 3: Phase 2 - ToolAnnotations (Medium/Low Priority)
|
||||||
|
- Complete remaining tools (~49 tools)
|
||||||
|
- Deck, Tables, Contacts, Cookbook, News
|
||||||
|
- Update tool listings in README
|
||||||
|
|
||||||
|
### Week 4: Phase 3 - Parameter Descriptions
|
||||||
|
- Add Field() descriptions to Critical/High priority tools
|
||||||
|
- Start with OAuth, Notes, WebDAV modules
|
||||||
|
- Incremental completion over time
|
||||||
|
|
||||||
|
## Benefits
|
||||||
|
|
||||||
|
### For Users
|
||||||
|
- **Clearer UI**: "Create Note" vs "nc_notes_create_note"
|
||||||
|
- **Safety**: Warnings before destructive operations
|
||||||
|
- **Better help**: Parameter descriptions in auto-completion
|
||||||
|
- **Confidence**: Know which operations are safe to retry
|
||||||
|
|
||||||
|
### For MCP Clients
|
||||||
|
- **Caching**: Cache results from read-only tools
|
||||||
|
- **Safety prompts**: Warn before destructiveHint=true
|
||||||
|
- **Retry logic**: Safely retry idempotent operations
|
||||||
|
- **UI organization**: Group by behavior (reads vs writes vs deletes)
|
||||||
|
- **Performance**: Optimize based on hints
|
||||||
|
|
||||||
|
### For Developers
|
||||||
|
- **Self-documenting**: Behavior is explicit
|
||||||
|
- **Consistency**: Standard patterns across codebase
|
||||||
|
- **Testing**: Validate annotations match implementation
|
||||||
|
- **Maintenance**: Clear expectations for new tools
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
- Immediate UX improvement with minimal effort
|
||||||
|
- Clients can make smarter decisions
|
||||||
|
- Self-documenting code
|
||||||
|
- Follows MCP best practices
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
- Initial effort to add annotations (12-15 hours total)
|
||||||
|
- Must maintain annotations when adding new tools
|
||||||
|
- Risk of incorrect annotations misleading clients
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
- Annotations are hints, not guarantees
|
||||||
|
- Clients may ignore annotations
|
||||||
|
- Backward compatible (additive change)
|
||||||
|
|
||||||
|
### Mitigations
|
||||||
|
- **Incorrect annotations**: Add tests validating behavior matches hints
|
||||||
|
- **Maintenance burden**: Add to code review checklist and tool template
|
||||||
|
- **Documentation**: Update CLAUDE.md with annotation guidelines
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Delete)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Deletes data permanently
|
||||||
|
idempotentHint=True, # Same end state (note doesn't exist)
|
||||||
|
openWorldHint=True # Nextcloud is external
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_delete_note(
|
||||||
|
note_id: int = Field(description="The ID of the note to delete permanently"),
|
||||||
|
ctx: Context
|
||||||
|
) -> DeleteNoteResponse:
|
||||||
|
"""Delete a note permanently (requires notes:write scope)"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Update)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # NOT idempotent: etag changes each update
|
||||||
|
openWorldHint=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:write")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_update_note(
|
||||||
|
note_id: int = Field(description="The ID of the note to update"),
|
||||||
|
title: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New title (omit to keep current)"
|
||||||
|
),
|
||||||
|
content: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New markdown content (omit to keep current)"
|
||||||
|
),
|
||||||
|
category: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="New category/folder (omit to keep current)"
|
||||||
|
),
|
||||||
|
etag: str = Field(
|
||||||
|
description="ETag from get_note (prevents concurrent modification)"
|
||||||
|
),
|
||||||
|
ctx: Context
|
||||||
|
) -> UpdateNoteResponse:
|
||||||
|
"""Update an existing note's title, content, or category.
|
||||||
|
|
||||||
|
The etag parameter is required to prevent overwriting concurrent changes.
|
||||||
|
Get the current ETag by first calling nc_notes_get_note.
|
||||||
|
If the note has been modified since you retrieved it, the update will fail.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Complete Annotated Tool (Read-Only)
|
||||||
|
|
||||||
|
```python
|
||||||
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Doesn't modify data
|
||||||
|
openWorldHint=True # Queries Nextcloud
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@require_scopes("notes:read")
|
||||||
|
@instrument_tool
|
||||||
|
async def nc_notes_search_notes(
|
||||||
|
query: str = Field(description="Search term to match in note titles or content"),
|
||||||
|
ctx: Context
|
||||||
|
) -> SearchNotesResponse:
|
||||||
|
"""Search notes by title or content, returning id, title, and category.
|
||||||
|
|
||||||
|
This is a read-only operation that searches across all user notes.
|
||||||
|
Use nc_notes_get_note to retrieve the full content of matching notes.
|
||||||
|
"""
|
||||||
|
client = await get_client(ctx)
|
||||||
|
# ... implementation ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
Add tests validating annotation presence and correctness:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_notes_tools_have_annotations():
|
||||||
|
"""Verify all notes tools have appropriate annotations."""
|
||||||
|
tools = get_registered_tools(mcp)
|
||||||
|
|
||||||
|
# Check create tool
|
||||||
|
create_tool = tools["nc_notes_create_note"]
|
||||||
|
assert create_tool.title == "Create Note"
|
||||||
|
assert create_tool.annotations.idempotentHint is False
|
||||||
|
|
||||||
|
# Check delete tool
|
||||||
|
delete_tool = tools["nc_notes_delete_note"]
|
||||||
|
assert delete_tool.title == "Delete Note"
|
||||||
|
assert delete_tool.annotations.destructiveHint is True
|
||||||
|
assert delete_tool.annotations.idempotentHint is True
|
||||||
|
|
||||||
|
# Check read-only tool
|
||||||
|
search_tool = tools["nc_notes_search_notes"]
|
||||||
|
assert search_tool.title == "Search Notes"
|
||||||
|
assert search_tool.annotations.readOnlyHint is True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Verify existing tests pass with annotations
|
||||||
|
- Manual testing in MCP inspector/client
|
||||||
|
|
||||||
|
### Documentation Updates
|
||||||
|
- Update README tool listings with new titles
|
||||||
|
- Add annotation guidelines to CLAUDE.md
|
||||||
|
- Include examples in developer documentation
|
||||||
|
|
||||||
|
## Resolved Questions
|
||||||
|
|
||||||
|
1. **WebDAV write_file idempotency** (Resolved: 2025-12-11)
|
||||||
|
- **Decision**: Mark as `idempotentHint=True`
|
||||||
|
- **Rationale**: Uses HTTP PUT without version control. Writing same content to same path repeatedly produces identical end state, which is the definition of idempotency in HTTP semantics.
|
||||||
|
|
||||||
|
2. **Semantic search openWorldHint** (Resolved: 2025-12-11)
|
||||||
|
- **Decision**: Mark as `openWorldHint=True`
|
||||||
|
- **Rationale**: For consistency with other Nextcloud tools. While the data being searched is "indexed/internal", Nextcloud itself is external to the MCP server. The fact that data is indexed is an implementation detail, not a fundamental difference from other Nextcloud queries.
|
||||||
|
|
||||||
|
3. **Read-only with side effects**: Should tools that log analytics still be readOnlyHint=true?
|
||||||
|
- **Decision**: Yes. Logging/analytics are non-visible side effects that don't change user-observable state. Read-only refers to data modifications that affect the user's content.
|
||||||
|
|
||||||
|
## Future Considerations
|
||||||
|
|
||||||
|
1. **Icons**: Visual icons for tools (requires design work, deferred to future ADR)
|
||||||
|
2. **Parameter descriptions**: Add Pydantic `Field(description=...)` for better auto-completion (Phase 3, future work)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- MCP Python SDK: `/home/chris/Software/python-sdk/`
|
||||||
|
- ToolAnnotations spec: `src/mcp/types.py:1247`
|
||||||
|
- FastMCP decorator: `src/mcp/server/fastmcp/server.py:444`
|
||||||
|
- Examples: `examples/fastmcp/parameter_descriptions.py`, `examples/fastmcp/icons_demo.py`
|
||||||
|
|
||||||
|
## Decision Timeline
|
||||||
|
|
||||||
|
- **Proposed**: 2025-12-11
|
||||||
|
- **Reviewed**: 2025-12-11 (Self-review during implementation)
|
||||||
|
- **Accepted**: 2025-12-11
|
||||||
|
- **Implemented**: 2025-12-11 (Phase 1 & 2 complete)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
# MCP 1.23.x DNS Rebinding Protection Fix
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
MCP Python SDK 1.23.0 introduced **automatic DNS rebinding protection** that breaks containerized deployments (Kubernetes, Docker) when the protection is unintentionally auto-enabled.
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
From `mcp/server/fastmcp/server.py:177-183` in the Python SDK:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
|
||||||
|
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
|
||||||
|
transport_security = TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"],
|
||||||
|
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### What Was Happening
|
||||||
|
|
||||||
|
1. **FastMCP initialization** in `app.py` didn't pass `host` or `transport_security` parameters
|
||||||
|
2. **Defaults applied**: `host="127.0.0.1"`, `transport_security=None`
|
||||||
|
3. **Auto-enablement triggered**: Condition `transport_security is None and host == "127.0.0.1"` was TRUE
|
||||||
|
4. **Protection activated** with `allowed_hosts=["127.0.0.1:*", "localhost:*", "[::1]:*"]`
|
||||||
|
5. **Kubernetes requests rejected**: `Host: nextcloud-mcp-server.default.svc.cluster.local:8000` didn't match allowed hosts
|
||||||
|
|
||||||
|
### Why `--host 0.0.0.0` Didn't Help
|
||||||
|
|
||||||
|
The `--host` CLI flag (used in Dockerfile/docker-compose) controls **uvicorn's bind address**, NOT the **FastMCP `host` parameter**. These are separate concerns:
|
||||||
|
|
||||||
|
- **Uvicorn bind address** (`--host 0.0.0.0`): Where the HTTP server listens
|
||||||
|
- **FastMCP host parameter** (defaulted to `"127.0.0.1"`): Used for auto-enablement logic
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Explicitly disable DNS rebinding protection by passing `transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)` to all FastMCP instances.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
Modified `nextcloud_mcp_server/app.py`:
|
||||||
|
|
||||||
|
1. **Import** `TransportSecuritySettings` from `mcp.server.transport_security`
|
||||||
|
2. **Updated all three FastMCP initializations**:
|
||||||
|
- OAuth mode (line 1015)
|
||||||
|
- Smithery stateless mode (line 1030)
|
||||||
|
- BasicAuth mode (line 1040)
|
||||||
|
|
||||||
|
Each now includes:
|
||||||
|
```python
|
||||||
|
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
### ✅ What This Fixes
|
||||||
|
|
||||||
|
- **Kubernetes deployments**: Requests with k8s service DNS names now work
|
||||||
|
- **Docker deployments**: Port-mapped requests (localhost:8000 → container) now work
|
||||||
|
- **Reverse proxy deployments**: Proxied requests with various Host headers now work
|
||||||
|
- **Ingress controllers**: Requests via ingress hostnames now work
|
||||||
|
|
||||||
|
### 🔒 Security Considerations
|
||||||
|
|
||||||
|
DNS rebinding protection defends against attacks where:
|
||||||
|
1. Attacker controls a DNS domain (e.g., `evil.com`)
|
||||||
|
2. DNS initially resolves to attacker's IP
|
||||||
|
3. After victim's browser caches the origin, DNS changes to victim's localhost
|
||||||
|
4. Attacker's page can now make requests to victim's localhost services
|
||||||
|
|
||||||
|
**Why it's safe to disable for this deployment:**
|
||||||
|
|
||||||
|
1. **OAuth authentication required** in production deployments (ADR-002, ADR-004)
|
||||||
|
2. **Network-level isolation** in containerized environments (k8s network policies, Docker networks)
|
||||||
|
3. **MCP is server-to-server**, not exposed to browsers (no CORS concerns)
|
||||||
|
4. **Host header validation inappropriate** for multi-tenant k8s environments
|
||||||
|
|
||||||
|
If DNS rebinding protection is needed for specific deployments, it can be re-enabled with a custom allowed hosts list:
|
||||||
|
|
||||||
|
```python
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=True,
|
||||||
|
allowed_hosts=[
|
||||||
|
"nextcloud-mcp-server.default.svc.cluster.local:*",
|
||||||
|
"mcp.example.com:*",
|
||||||
|
# Add all your expected Host header values
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- ✅ Ruff linting passes
|
||||||
|
- ✅ Type checking passes (pre-existing warnings unrelated)
|
||||||
|
- ✅ Module imports successfully
|
||||||
|
- ✅ Compatible with MCP 1.23.x
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [MCP Python SDK 1.23.0 Release](https://github.com/modelcontextprotocol/python-sdk/releases/tag/v1.23.0)
|
||||||
|
- Commit: `d3a1841` - "Auto-enable DNS rebinding protection for localhost servers"
|
||||||
|
- Issue #373 (original report of k8s breakage)
|
||||||
|
- PR #382 (MCP 1.23.x upgrade)
|
||||||
@@ -5,7 +5,7 @@ This document explains the architecture of the semantic search feature in the Ne
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> **Status: Experimental**
|
> **Status: Experimental**
|
||||||
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
> - Disabled by default (`VECTOR_SYNC_ENABLED=false`)
|
||||||
> - Currently supports **Notes app only** (multi-app architecture ready, additional apps planned)
|
> - Currently supports **Notes, Files (PDFs), News items, and Deck cards**
|
||||||
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
> - Requires additional infrastructure (Qdrant vector database + Ollama embedding service)
|
||||||
> - RAG answer generation requires MCP client sampling support
|
> - RAG answer generation requires MCP client sampling support
|
||||||
|
|
||||||
@@ -39,9 +39,9 @@ Semantic search enables:
|
|||||||
|
|
||||||
### Current Support
|
### Current Support
|
||||||
|
|
||||||
- **Supported Apps**: Notes (fully implemented)
|
- **Supported Apps**: Notes, Files (PDFs with text extraction), News items, Deck cards
|
||||||
- **Planned Apps**: Calendar events, Calendar tasks, Deck cards, Files (with text extraction), Contacts
|
- **Planned Apps**: Calendar events, Calendar tasks, Contacts
|
||||||
- **Architecture**: Multi-app plugin system ready, awaiting implementation
|
- **Architecture**: Multi-app plugin system ready for additional apps
|
||||||
|
|
||||||
## System Components
|
## System Components
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import httpx
|
|||||||
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
||||||
from mcp.server.auth.settings import AuthSettings
|
from mcp.server.auth.settings import AuthSettings
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.server.transport_security import TransportSecuritySettings
|
||||||
from pydantic import AnyHttpUrl
|
from pydantic import AnyHttpUrl
|
||||||
from starlette.applications import Starlette
|
from starlette.applications import Starlette
|
||||||
from starlette.middleware.authentication import AuthenticationMiddleware
|
from starlette.middleware.authentication import AuthenticationMiddleware
|
||||||
@@ -60,6 +61,7 @@ from nextcloud_mcp_server.server import (
|
|||||||
configure_contacts_tools,
|
configure_contacts_tools,
|
||||||
configure_cookbook_tools,
|
configure_cookbook_tools,
|
||||||
configure_deck_tools,
|
configure_deck_tools,
|
||||||
|
configure_news_tools,
|
||||||
configure_notes_tools,
|
configure_notes_tools,
|
||||||
configure_semantic_tools,
|
configure_semantic_tools,
|
||||||
configure_sharing_tools,
|
configure_sharing_tools,
|
||||||
@@ -514,7 +516,7 @@ async def load_oauth_client_credentials(
|
|||||||
# and the authorization server will limit them to these allowed scopes.
|
# and the authorization server will limit them to these allowed scopes.
|
||||||
#
|
#
|
||||||
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
# 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"
|
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"
|
||||||
|
|
||||||
# Add offline_access scope if refresh tokens are enabled
|
# Add offline_access scope if refresh tokens are enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
@@ -1015,6 +1017,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
lifespan=oauth_lifespan,
|
lifespan=oauth_lifespan,
|
||||||
token_verifier=token_verifier,
|
token_verifier=token_verifier,
|
||||||
auth=auth_settings,
|
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:
|
else:
|
||||||
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
# ADR-016: Use Smithery lifespan for stateless mode, BasicAuth otherwise
|
||||||
@@ -1023,11 +1030,26 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# json_response=True returns plain JSON-RPC instead of SSE format,
|
# json_response=True returns plain JSON-RPC instead of SSE format,
|
||||||
# required for Smithery scanner compatibility
|
# required for Smithery scanner compatibility
|
||||||
mcp = FastMCP(
|
mcp = FastMCP(
|
||||||
"Nextcloud MCP", lifespan=app_lifespan_smithery, json_response=True
|
"Nextcloud MCP",
|
||||||
|
lifespan=app_lifespan_smithery,
|
||||||
|
json_response=True,
|
||||||
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=False
|
||||||
|
),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info("Configuring MCP server for BasicAuth mode")
|
logger.info("Configuring MCP server for BasicAuth mode")
|
||||||
mcp = FastMCP("Nextcloud MCP", lifespan=app_lifespan_basic)
|
mcp = FastMCP(
|
||||||
|
"Nextcloud MCP",
|
||||||
|
lifespan=app_lifespan_basic,
|
||||||
|
# Disable DNS rebinding protection for containerized deployments (k8s, Docker)
|
||||||
|
# MCP 1.23+ auto-enables this for localhost, breaking k8s service DNS names
|
||||||
|
transport_security=TransportSecuritySettings(
|
||||||
|
enable_dns_rebinding_protection=False
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.resource("nc://capabilities")
|
@mcp.resource("nc://capabilities")
|
||||||
async def nc_get_capabilities():
|
async def nc_get_capabilities():
|
||||||
@@ -1046,6 +1068,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
"contacts": configure_contacts_tools,
|
"contacts": configure_contacts_tools,
|
||||||
"cookbook": configure_cookbook_tools,
|
"cookbook": configure_cookbook_tools,
|
||||||
"deck": configure_deck_tools,
|
"deck": configure_deck_tools,
|
||||||
|
"news": configure_news_tools,
|
||||||
}
|
}
|
||||||
|
|
||||||
# If no specific apps are specified, enable all
|
# If no specific apps are specified, enable all
|
||||||
|
|||||||
@@ -201,8 +201,15 @@ function vizApp() {
|
|||||||
return `${baseUrl}/apps/calendar`;
|
return `${baseUrl}/apps/calendar`;
|
||||||
case 'contact':
|
case 'contact':
|
||||||
return `${baseUrl}/apps/contacts`;
|
return `${baseUrl}/apps/contacts`;
|
||||||
case 'deck':
|
case 'deck_card':
|
||||||
|
// URL pattern: /apps/deck/board/:boardId/card/:cardId
|
||||||
|
if (result.metadata && result.metadata.board_id) {
|
||||||
|
return `${baseUrl}/apps/deck/board/${result.metadata.board_id}/card/${result.id}`;
|
||||||
|
}
|
||||||
|
// Fallback if board_id not available
|
||||||
return `${baseUrl}/apps/deck`;
|
return `${baseUrl}/apps/deck`;
|
||||||
|
case 'news_item':
|
||||||
|
return `${baseUrl}/apps/news/item/${result.id}`;
|
||||||
default:
|
default:
|
||||||
return `${baseUrl}`;
|
return `${baseUrl}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,12 @@
|
|||||||
<span>Contacts</span>
|
<span>Contacts</span>
|
||||||
</label>
|
</label>
|
||||||
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||||
<input type="checkbox" x-model="docTypes" value="deck" style="margin-right: 4px;">
|
<input type="checkbox" x-model="docTypes" value="deck_card" style="margin-right: 4px;">
|
||||||
<span>Deck</span>
|
<span>Deck Cards</span>
|
||||||
|
</label>
|
||||||
|
<label style="display: flex; align-items: center; cursor: pointer; font-weight: normal;">
|
||||||
|
<input type="checkbox" x-model="docTypes" value="news_item" style="margin-right: 4px;">
|
||||||
|
<span>News</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -303,10 +303,13 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# Introspection requires client authentication
|
# 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(
|
response = await self.http_client.post(
|
||||||
self.introspection_uri,
|
self.introspection_uri,
|
||||||
data={"token": token},
|
data={"token": token},
|
||||||
auth=(self.settings.oidc_client_id, self.settings.oidc_client_secret),
|
auth=(client_id, client_secret),
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -298,6 +298,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
"title": r.title,
|
"title": r.title,
|
||||||
"excerpt": r.excerpt,
|
"excerpt": r.excerpt,
|
||||||
"score": r.score,
|
"score": r.score,
|
||||||
|
"metadata": r.metadata,
|
||||||
}
|
}
|
||||||
for r in search_results
|
for r in search_results
|
||||||
],
|
],
|
||||||
@@ -458,6 +459,7 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
), # Raw score from algorithm
|
), # Raw score from algorithm
|
||||||
"chunk_start_offset": r.chunk_start_offset,
|
"chunk_start_offset": r.chunk_start_offset,
|
||||||
"chunk_end_offset": r.chunk_end_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
|
for r in search_results
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
assert nextcloud_host is not None # Type narrowing for type checker
|
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(
|
return httpx.AsyncClient(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from .contacts import ContactsClient
|
|||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
from .deck import DeckClient
|
from .deck import DeckClient
|
||||||
from .groups import GroupsClient
|
from .groups import GroupsClient
|
||||||
|
from .news import NewsClient
|
||||||
from .notes import NotesClient
|
from .notes import NotesClient
|
||||||
from .sharing import SharingClient
|
from .sharing import SharingClient
|
||||||
from .tables import TablesClient
|
from .tables import TablesClient
|
||||||
@@ -81,6 +82,7 @@ class NextcloudClient:
|
|||||||
self.contacts = ContactsClient(self._client, username)
|
self.contacts = ContactsClient(self._client, username)
|
||||||
self.cookbook = CookbookClient(self._client, username)
|
self.cookbook = CookbookClient(self._client, username)
|
||||||
self.deck = DeckClient(self._client, username)
|
self.deck = DeckClient(self._client, username)
|
||||||
|
self.news = NewsClient(self._client, username)
|
||||||
self.users = UsersClient(self._client, username)
|
self.users = UsersClient(self._client, username)
|
||||||
self.groups = GroupsClient(self._client, username)
|
self.groups = GroupsClient(self._client, username)
|
||||||
self.sharing = SharingClient(self._client, username)
|
self.sharing = SharingClient(self._client, username)
|
||||||
|
|||||||
@@ -0,0 +1,394 @@
|
|||||||
|
"""Client for Nextcloud News app operations."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from enum import IntEnum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from .base import BaseNextcloudClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemType(IntEnum):
|
||||||
|
"""Type constants for News API item queries."""
|
||||||
|
|
||||||
|
FEED = 0 # Single feed
|
||||||
|
FOLDER = 1 # Folder and its feeds
|
||||||
|
STARRED = 2 # All starred items
|
||||||
|
ALL = 3 # All items
|
||||||
|
|
||||||
|
|
||||||
|
class NewsClient(BaseNextcloudClient):
|
||||||
|
"""Client for Nextcloud News app operations."""
|
||||||
|
|
||||||
|
app_name = "news"
|
||||||
|
API_BASE = "/apps/news/api/v1-3"
|
||||||
|
|
||||||
|
# --- Folders ---
|
||||||
|
|
||||||
|
async def get_folders(self) -> list[dict[str, Any]]:
|
||||||
|
"""Get all folders."""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/folders")
|
||||||
|
return response.json().get("folders", [])
|
||||||
|
|
||||||
|
async def create_folder(self, name: str) -> dict[str, Any]:
|
||||||
|
"""Create a new folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Folder name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created folder data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 409 if folder name already exists,
|
||||||
|
422 if name is empty
|
||||||
|
"""
|
||||||
|
response = await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/folders", json={"name": name}
|
||||||
|
)
|
||||||
|
folders = response.json().get("folders", [])
|
||||||
|
return folders[0] if folders else {}
|
||||||
|
|
||||||
|
async def rename_folder(self, folder_id: int, name: str) -> None:
|
||||||
|
"""Rename a folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
name: New folder name
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found, 409 if name exists
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"PUT", f"{self.API_BASE}/folders/{folder_id}", json={"name": name}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_folder(self, folder_id: int) -> None:
|
||||||
|
"""Delete a folder and all its feeds/items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found
|
||||||
|
"""
|
||||||
|
await self._make_request("DELETE", f"{self.API_BASE}/folders/{folder_id}")
|
||||||
|
|
||||||
|
async def mark_folder_read(self, folder_id: int, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items in a folder as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
folder_id: Folder ID
|
||||||
|
newest_item_id: ID of newest item to mark read (prevents marking
|
||||||
|
items user hasn't seen yet)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if folder not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/folders/{folder_id}/read",
|
||||||
|
json={"newestItemId": newest_item_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Feeds ---
|
||||||
|
|
||||||
|
async def get_feeds(self) -> dict[str, Any]:
|
||||||
|
"""Get all feeds with metadata.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys:
|
||||||
|
- feeds: List of feed objects
|
||||||
|
- starredCount: Number of starred items
|
||||||
|
- newestItemId: ID of newest item (omitted if no items)
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/feeds")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def create_feed(
|
||||||
|
self, url: str, folder_id: int | None = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Subscribe to a new feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Feed URL
|
||||||
|
folder_id: Optional folder ID (None for root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created feed data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 409 if feed already exists, 422 if URL is invalid
|
||||||
|
"""
|
||||||
|
body: dict[str, Any] = {"url": url}
|
||||||
|
if folder_id is not None:
|
||||||
|
body["folderId"] = folder_id
|
||||||
|
response = await self._make_request("POST", f"{self.API_BASE}/feeds", json=body)
|
||||||
|
data = response.json()
|
||||||
|
feeds = data.get("feeds", [])
|
||||||
|
return feeds[0] if feeds else {}
|
||||||
|
|
||||||
|
async def delete_feed(self, feed_id: int) -> None:
|
||||||
|
"""Unsubscribe from a feed (deletes all items).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request("DELETE", f"{self.API_BASE}/feeds/{feed_id}")
|
||||||
|
|
||||||
|
async def move_feed(self, feed_id: int, folder_id: int | None) -> None:
|
||||||
|
"""Move a feed to a different folder.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
folder_id: Target folder ID (None for root)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/move",
|
||||||
|
json={"folderId": folder_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def rename_feed(self, feed_id: int, title: str) -> None:
|
||||||
|
"""Rename a feed.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
title: New feed title
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/rename",
|
||||||
|
json={"feedTitle": title},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_feed_read(self, feed_id: int, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items in a feed as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feed_id: Feed ID
|
||||||
|
newest_item_id: ID of newest item to mark read
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if feed not found
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/feeds/{feed_id}/read",
|
||||||
|
json={"newestItemId": newest_item_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Items ---
|
||||||
|
|
||||||
|
async def get_items(
|
||||||
|
self,
|
||||||
|
batch_size: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
type_: int = NewsItemType.ALL,
|
||||||
|
id_: int = 0,
|
||||||
|
get_read: bool = True,
|
||||||
|
oldest_first: bool = False,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get items (articles) with filtering.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
batch_size: Number of items to return (-1 for all)
|
||||||
|
offset: Item ID to start after (for pagination)
|
||||||
|
type_: Item type filter (NewsItemType)
|
||||||
|
id_: Feed/folder ID (ignored for STARRED/ALL types)
|
||||||
|
get_read: Include read items
|
||||||
|
oldest_first: Sort oldest first instead of newest
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of item objects
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"batchSize": batch_size,
|
||||||
|
"offset": offset,
|
||||||
|
"type": type_,
|
||||||
|
"id": id_,
|
||||||
|
"getRead": str(get_read).lower(),
|
||||||
|
"oldestFirst": str(oldest_first).lower(),
|
||||||
|
}
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"{self.API_BASE}/items", params=params
|
||||||
|
)
|
||||||
|
return response.json().get("items", [])
|
||||||
|
|
||||||
|
async def get_item(self, item_id: int) -> dict[str, Any]:
|
||||||
|
"""Get a specific item by ID.
|
||||||
|
|
||||||
|
Note: The News API doesn't have a direct single-item endpoint,
|
||||||
|
so we fetch all items and filter. For efficiency, consider
|
||||||
|
caching or using get_items with specific feed if known.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Item data
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If item not found
|
||||||
|
"""
|
||||||
|
# Fetch all items and find the one we need
|
||||||
|
# This is inefficient but the API doesn't provide a direct endpoint
|
||||||
|
items = await self.get_items(batch_size=-1, get_read=True)
|
||||||
|
for item in items:
|
||||||
|
if item.get("id") == item_id:
|
||||||
|
return item
|
||||||
|
raise ValueError(f"Item {item_id} not found")
|
||||||
|
|
||||||
|
async def get_updated_items(
|
||||||
|
self,
|
||||||
|
last_modified: int,
|
||||||
|
type_: int = NewsItemType.ALL,
|
||||||
|
id_: int = 0,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Get items modified since a timestamp (for delta sync).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_modified: Unix timestamp (seconds or microseconds)
|
||||||
|
type_: Item type filter
|
||||||
|
id_: Feed/folder ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of modified items (includes deleted items)
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"lastModified": last_modified,
|
||||||
|
"type": type_,
|
||||||
|
"id": id_,
|
||||||
|
}
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET", f"{self.API_BASE}/items/updated", params=params
|
||||||
|
)
|
||||||
|
return response.json().get("items", [])
|
||||||
|
|
||||||
|
async def mark_item_read(self, item_id: int) -> None:
|
||||||
|
"""Mark a single item as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/read")
|
||||||
|
|
||||||
|
async def mark_item_unread(self, item_id: int) -> None:
|
||||||
|
"""Mark a single item as unread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unread")
|
||||||
|
|
||||||
|
async def star_item(self, item_id: int) -> None:
|
||||||
|
"""Star (favorite) a single item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/star")
|
||||||
|
|
||||||
|
async def unstar_item(self, item_id: int) -> None:
|
||||||
|
"""Unstar a single item.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_id: Item ID
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPStatusError: 404 if item not found
|
||||||
|
"""
|
||||||
|
await self._make_request("POST", f"{self.API_BASE}/items/{item_id}/unstar")
|
||||||
|
|
||||||
|
async def mark_items_read(self, item_ids: list[int]) -> None:
|
||||||
|
"""Mark multiple items as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/read/multiple", json={"itemIds": item_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_items_unread(self, item_ids: list[int]) -> None:
|
||||||
|
"""Mark multiple items as unread.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/items/unread/multiple",
|
||||||
|
json={"itemIds": item_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def star_items(self, item_ids: list[int]) -> None:
|
||||||
|
"""Star multiple items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/star/multiple", json={"itemIds": item_ids}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unstar_items(self, item_ids: list[int]) -> None:
|
||||||
|
"""Unstar multiple items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
item_ids: List of item IDs
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST",
|
||||||
|
f"{self.API_BASE}/items/unstar/multiple",
|
||||||
|
json={"itemIds": item_ids},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_all_read(self, newest_item_id: int) -> None:
|
||||||
|
"""Mark all items as read.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
newest_item_id: ID of newest item to mark read
|
||||||
|
"""
|
||||||
|
await self._make_request(
|
||||||
|
"POST", f"{self.API_BASE}/items/read", json={"newestItemId": newest_item_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Status ---
|
||||||
|
|
||||||
|
async def get_status(self) -> dict[str, Any]:
|
||||||
|
"""Get News app status and configuration.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with version and warnings
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/status")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_version(self) -> str:
|
||||||
|
"""Get News app version.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Version string (e.g., "25.0.0")
|
||||||
|
"""
|
||||||
|
response = await self._make_request("GET", f"{self.API_BASE}/version")
|
||||||
|
return response.json().get("version", "")
|
||||||
@@ -1174,7 +1174,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
if display_name_elem is not None and display_name_elem.text == tag_name:
|
if display_name_elem is not None and display_name_elem.text == tag_name:
|
||||||
tag_info = {
|
tag_info = {
|
||||||
"id": int(tag_id_elem.text) if tag_id_elem is not None else None,
|
"id": int(tag_id_elem.text)
|
||||||
|
if tag_id_elem is not None and tag_id_elem.text is not None
|
||||||
|
else None,
|
||||||
"name": display_name_elem.text,
|
"name": display_name_elem.text,
|
||||||
"userVisible": user_visible_elem.text.lower() == "true"
|
"userVisible": user_visible_elem.text.lower() == "true"
|
||||||
if user_visible_elem is not None
|
if user_visible_elem is not None
|
||||||
@@ -1369,7 +1371,9 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_info = {
|
file_info = {
|
||||||
"id": int(fileid_elem.text) if fileid_elem is not None else None,
|
"id": int(fileid_elem.text)
|
||||||
|
if fileid_elem is not None and fileid_elem.text is not None
|
||||||
|
else None,
|
||||||
"path": path,
|
"path": path,
|
||||||
"name": displayname_elem.text
|
"name": displayname_elem.text
|
||||||
if displayname_elem is not None
|
if displayname_elem is not None
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
"""Pydantic models for Nextcloud News app responses."""
|
||||||
|
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from .base import BaseResponse
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFolder(BaseModel):
|
||||||
|
"""Model for a News folder."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Folder ID")
|
||||||
|
name: str = Field(description="Folder name")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsFeed(BaseModel):
|
||||||
|
"""Model for a News feed (RSS/Atom subscription)."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Feed ID")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
favicon_link: str | None = Field(
|
||||||
|
None, alias="faviconLink", description="Favicon URL"
|
||||||
|
)
|
||||||
|
link: str | None = Field(None, description="Website link")
|
||||||
|
added: int = Field(description="Unix timestamp when feed was added")
|
||||||
|
folder_id: int | None = Field(
|
||||||
|
None, alias="folderId", description="Parent folder ID"
|
||||||
|
)
|
||||||
|
unread_count: int = Field(
|
||||||
|
0, alias="unreadCount", description="Number of unread items"
|
||||||
|
)
|
||||||
|
ordering: int = Field(
|
||||||
|
0, description="Feed ordering (0=default, 1=oldest, 2=newest)"
|
||||||
|
)
|
||||||
|
pinned: bool = Field(False, description="Whether feed is pinned to top")
|
||||||
|
update_error_count: int = Field(
|
||||||
|
0, alias="updateErrorCount", description="Consecutive update failures"
|
||||||
|
)
|
||||||
|
last_update_error: str | None = Field(
|
||||||
|
None, alias="lastUpdateError", description="Last update error message"
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_errors(self) -> bool:
|
||||||
|
"""Check if feed has update errors."""
|
||||||
|
return self.update_error_count > 0
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItem(BaseModel):
|
||||||
|
"""Model for a News item (article) with full content."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
guid: str = Field(description="Globally unique identifier")
|
||||||
|
guid_hash: str = Field(alias="guidHash", description="MD5 hash of GUID")
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
body: str | None = Field(None, description="Article content (HTML)")
|
||||||
|
enclosure_mime: str | None = Field(
|
||||||
|
None, alias="enclosureMime", description="Enclosure MIME type"
|
||||||
|
)
|
||||||
|
enclosure_link: str | None = Field(
|
||||||
|
None, alias="enclosureLink", description="Enclosure URL"
|
||||||
|
)
|
||||||
|
media_thumbnail: str | None = Field(
|
||||||
|
None, alias="mediaThumbnail", description="Media thumbnail URL"
|
||||||
|
)
|
||||||
|
media_description: str | None = Field(
|
||||||
|
None, alias="mediaDescription", description="Media description"
|
||||||
|
)
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
rtl: bool = Field(False, description="Right-to-left text")
|
||||||
|
last_modified: int = Field(
|
||||||
|
alias="lastModified", description="Last modification timestamp"
|
||||||
|
)
|
||||||
|
fingerprint: str | None = Field(
|
||||||
|
None, description="Content fingerprint for deduplication"
|
||||||
|
)
|
||||||
|
content_hash: str | None = Field(
|
||||||
|
None, alias="contentHash", description="Content hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsItemSummary(BaseModel):
|
||||||
|
"""Lightweight model for News item list responses."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
id: int = Field(description="Item ID")
|
||||||
|
title: str = Field(description="Article title")
|
||||||
|
feed_id: int = Field(alias="feedId", description="Parent feed ID")
|
||||||
|
unread: bool = Field(True, description="Whether item is unread")
|
||||||
|
starred: bool = Field(False, description="Whether item is starred")
|
||||||
|
pub_date: int | None = Field(
|
||||||
|
None, alias="pubDate", description="Publication timestamp"
|
||||||
|
)
|
||||||
|
url: str | None = Field(None, description="Article URL")
|
||||||
|
author: str | None = Field(None, description="Article author")
|
||||||
|
|
||||||
|
|
||||||
|
class NewsStatus(BaseModel):
|
||||||
|
"""Model for News app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
|
|
||||||
|
|
||||||
|
# --- Response Models ---
|
||||||
|
|
||||||
|
|
||||||
|
class ListFoldersResponse(BaseResponse):
|
||||||
|
"""Response model for listing folders."""
|
||||||
|
|
||||||
|
results: List[NewsFolder] = Field(description="List of folders")
|
||||||
|
total_count: int = Field(description="Total number of folders")
|
||||||
|
|
||||||
|
|
||||||
|
class ListFeedsResponse(BaseResponse):
|
||||||
|
"""Response model for listing feeds."""
|
||||||
|
|
||||||
|
results: List[NewsFeed] = Field(description="List of feeds")
|
||||||
|
starred_count: int = Field(0, description="Number of starred items")
|
||||||
|
newest_item_id: int | None = Field(None, description="ID of newest item")
|
||||||
|
total_count: int = Field(description="Total number of feeds")
|
||||||
|
|
||||||
|
|
||||||
|
class ListItemsResponse(BaseResponse):
|
||||||
|
"""Response model for listing items."""
|
||||||
|
|
||||||
|
results: List[NewsItemSummary] = Field(description="List of items")
|
||||||
|
total_count: int = Field(description="Number of items returned")
|
||||||
|
has_more: bool = Field(False, description="Whether more items exist")
|
||||||
|
oldest_id: int | None = Field(None, description="Oldest item ID (for pagination)")
|
||||||
|
|
||||||
|
|
||||||
|
class GetItemResponse(BaseResponse):
|
||||||
|
"""Response model for getting a single item."""
|
||||||
|
|
||||||
|
item: NewsItem = Field(description="Full item details")
|
||||||
|
|
||||||
|
|
||||||
|
class FeedHealthResponse(BaseResponse):
|
||||||
|
"""Response model for feed health status."""
|
||||||
|
|
||||||
|
feed_id: int = Field(description="Feed ID")
|
||||||
|
title: str = Field(description="Feed title")
|
||||||
|
url: str = Field(description="Feed URL")
|
||||||
|
has_errors: bool = Field(description="Whether feed has update errors")
|
||||||
|
error_count: int = Field(description="Number of consecutive errors")
|
||||||
|
last_error: str | None = Field(None, description="Last error message")
|
||||||
|
|
||||||
|
|
||||||
|
class GetStatusResponse(BaseResponse):
|
||||||
|
"""Response model for app status."""
|
||||||
|
|
||||||
|
version: str = Field(description="News app version")
|
||||||
|
warnings: dict = Field(default_factory=dict, description="Configuration warnings")
|
||||||
@@ -60,7 +60,7 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
|
|
||||||
def add_fields(
|
def add_fields(
|
||||||
self,
|
self,
|
||||||
log_record: dict[str, Any],
|
log_data: dict[str, Any],
|
||||||
record: logging.LogRecord,
|
record: logging.LogRecord,
|
||||||
message_dict: dict[str, Any],
|
message_dict: dict[str, Any],
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -68,28 +68,28 @@ class TraceContextFormatter(JsonFormatter):
|
|||||||
Add custom fields to the log record, including trace context.
|
Add custom fields to the log record, including trace context.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
log_record: Dictionary to be serialized as JSON
|
log_data: Dictionary to be serialized as JSON
|
||||||
record: LogRecord instance
|
record: LogRecord instance
|
||||||
message_dict: Dictionary of extra fields from log call
|
message_dict: Dictionary of extra fields from log call
|
||||||
"""
|
"""
|
||||||
# Call parent to add standard fields
|
# Call parent to add standard fields
|
||||||
super().add_fields(log_record, record, message_dict)
|
super().add_fields(log_data, record, message_dict)
|
||||||
|
|
||||||
# Add trace context if available
|
# Add trace context if available
|
||||||
trace_context = get_trace_context()
|
trace_context = get_trace_context()
|
||||||
if trace_context:
|
if trace_context:
|
||||||
log_record["trace_id"] = trace_context.get("trace_id")
|
log_data["trace_id"] = trace_context.get("trace_id")
|
||||||
log_record["span_id"] = trace_context.get("span_id")
|
log_data["span_id"] = trace_context.get("span_id")
|
||||||
|
|
||||||
# Add standard fields with consistent naming
|
# Add standard fields with consistent naming
|
||||||
log_record["timestamp"] = self.formatTime(record)
|
log_data["timestamp"] = self.formatTime(record)
|
||||||
log_record["level"] = record.levelname
|
log_data["level"] = record.levelname
|
||||||
log_record["logger"] = record.name
|
log_data["logger"] = record.name
|
||||||
log_record["message"] = record.getMessage()
|
log_data["message"] = record.getMessage()
|
||||||
|
|
||||||
# Include exception info if present
|
# Include exception info if present
|
||||||
if record.exc_info:
|
if record.exc_info:
|
||||||
log_record["exception"] = self.formatException(record.exc_info)
|
log_data["exception"] = self.formatException(record.exc_info)
|
||||||
|
|
||||||
|
|
||||||
class TraceContextTextFormatter(logging.Formatter):
|
class TraceContextTextFormatter(logging.Formatter):
|
||||||
|
|||||||
@@ -17,18 +17,20 @@ class AnthropicProvider(Provider):
|
|||||||
Note: Anthropic doesn't provide embedding models, only text generation.
|
Note: Anthropic doesn't provide embedding models, only text generation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, api_key: str, model: str = "claude-3-5-sonnet-20241022"):
|
def __init__(
|
||||||
|
self, api_key: str, generation_model: str = "claude-3-5-sonnet-20241022"
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Initialize Anthropic provider.
|
Initialize Anthropic provider.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
api_key: Anthropic API key
|
api_key: Anthropic API key
|
||||||
model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
generation_model: Model name (e.g., "claude-3-5-sonnet-20241022")
|
||||||
"""
|
"""
|
||||||
self.client = AsyncAnthropic(api_key=api_key)
|
self.client = AsyncAnthropic(api_key=api_key)
|
||||||
self.model = model
|
self.model = generation_model
|
||||||
|
|
||||||
logger.info(f"Initialized Anthropic provider (model={model})")
|
logger.info(f"Initialized Anthropic provider (model={self.model})")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def supports_embeddings(self) -> bool:
|
def supports_embeddings(self) -> bool:
|
||||||
|
|||||||
@@ -7,13 +7,48 @@ Supports:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
from openai import AsyncOpenAI
|
import anyio
|
||||||
|
from openai import AsyncOpenAI, RateLimitError
|
||||||
|
|
||||||
from .base import Provider
|
from .base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
# Well-known embedding dimensions for OpenAI models
|
||||||
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
OPENAI_EMBEDDING_DIMENSIONS: dict[str, int] = {
|
||||||
"text-embedding-3-small": 1536,
|
"text-embedding-3-small": 1536,
|
||||||
@@ -86,6 +121,7 @@ class OpenAIProvider(Provider):
|
|||||||
"""Whether this provider supports text generation."""
|
"""Whether this provider supports text generation."""
|
||||||
return self.generation_model is not None
|
return self.generation_model is not None
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def embed(self, text: str) -> list[float]:
|
async def embed(self, text: str) -> list[float]:
|
||||||
"""
|
"""
|
||||||
Generate embedding vector for text.
|
Generate embedding vector for text.
|
||||||
@@ -104,6 +140,7 @@ class OpenAIProvider(Provider):
|
|||||||
"Embedding not supported - no embedding_model configured"
|
"Embedding not supported - no embedding_model configured"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert self.embedding_model is not None # Type narrowing
|
||||||
response = await self.client.embeddings.create(
|
response = await self.client.embeddings.create(
|
||||||
input=text,
|
input=text,
|
||||||
model=self.embedding_model,
|
model=self.embedding_model,
|
||||||
@@ -151,14 +188,8 @@ class OpenAIProvider(Provider):
|
|||||||
for i in range(0, len(texts), batch_size):
|
for i in range(0, len(texts), batch_size):
|
||||||
batch = texts[i : i + batch_size]
|
batch = texts[i : i + batch_size]
|
||||||
|
|
||||||
response = await self.client.embeddings.create(
|
# Use helper method with retry logic for each batch
|
||||||
input=batch,
|
batch_embeddings = await self._embed_batch_request(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)
|
all_embeddings.extend(batch_embeddings)
|
||||||
|
|
||||||
# Update dimension if not set
|
# Update dimension if not set
|
||||||
@@ -171,6 +202,18 @@ class OpenAIProvider(Provider):
|
|||||||
|
|
||||||
return all_embeddings
|
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:
|
def get_dimension(self) -> int:
|
||||||
"""
|
"""
|
||||||
Get embedding dimension.
|
Get embedding dimension.
|
||||||
@@ -194,6 +237,7 @@ class OpenAIProvider(Provider):
|
|||||||
)
|
)
|
||||||
return self._dimension
|
return self._dimension
|
||||||
|
|
||||||
|
@retry_on_rate_limit
|
||||||
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
|
||||||
"""
|
"""
|
||||||
Generate text from a prompt.
|
Generate text from a prompt.
|
||||||
|
|||||||
@@ -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
|
with_vectors=False, # Don't need vectors for type discovery
|
||||||
)
|
)
|
||||||
|
|
||||||
doc_types = {
|
doc_types: set[str] = {
|
||||||
point.payload.get("doc_type")
|
str(point.payload.get("doc_type"))
|
||||||
for point in scroll_results
|
for point in scroll_results
|
||||||
if point.payload.get("doc_type")
|
if point.payload.get("doc_type")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,6 +204,8 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
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 can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
@@ -217,6 +219,18 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
|
|
||||||
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)
|
# Return unverified results (verification happens at output stage)
|
||||||
results.append(
|
results.append(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
@@ -225,11 +239,7 @@ class BM25HybridSearchAlgorithm(SearchAlgorithm):
|
|||||||
title=result.payload.get("title", "Untitled"),
|
title=result.payload.get("title", "Untitled"),
|
||||||
excerpt=result.payload.get("excerpt", ""),
|
excerpt=result.payload.get("excerpt", ""),
|
||||||
score=result.score, # Fusion score (RRF or DBSF)
|
score=result.score, # Fusion score (RRF or DBSF)
|
||||||
metadata={
|
metadata=metadata,
|
||||||
"chunk_index": result.payload.get("chunk_index"),
|
|
||||||
"total_chunks": result.payload.get("total_chunks"),
|
|
||||||
"search_method": f"bm25_hybrid_{self.fusion_name}",
|
|
||||||
},
|
|
||||||
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
chunk_start_offset=result.payload.get("chunk_start_offset"),
|
||||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||||
page_number=result.payload.get("page_number"),
|
page_number=result.payload.get("page_number"),
|
||||||
|
|||||||
@@ -209,6 +209,64 @@ async def _get_file_path_from_qdrant(
|
|||||||
return None
|
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
|
@dataclass
|
||||||
class ChunkContext:
|
class ChunkContext:
|
||||||
"""Expanded chunk with surrounding context and position markers.
|
"""Expanded chunk with surrounding context and position markers.
|
||||||
@@ -394,7 +452,9 @@ async def get_chunk_with_context(
|
|||||||
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
logger.debug(f"Resolved file_id {doc_id} to file_path {file_path}")
|
||||||
|
|
||||||
# Fetch full document text
|
# Fetch full document text
|
||||||
full_text = await _fetch_document_text(nc_client, resolved_doc_id, doc_type)
|
full_text = await _fetch_document_text(
|
||||||
|
nc_client, resolved_doc_id, doc_type, user_id
|
||||||
|
)
|
||||||
if full_text is None:
|
if full_text is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Could not fetch document text for {doc_type} {doc_id}, "
|
f"Could not fetch document text for {doc_type} {doc_id}, "
|
||||||
@@ -453,7 +513,7 @@ async def get_chunk_with_context(
|
|||||||
|
|
||||||
|
|
||||||
async def _fetch_document_text(
|
async def _fetch_document_text(
|
||||||
nc_client: NextcloudClient, doc_id: str | int, doc_type: str
|
nc_client: NextcloudClient, doc_id: str | int, doc_type: str, user_id: str
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Fetch full text content of a document.
|
"""Fetch full text content of a document.
|
||||||
|
|
||||||
@@ -524,6 +584,93 @@ async def _fetch_document_text(
|
|||||||
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
f"Error fetching file content for {doc_id}: {e}", exc_info=True
|
||||||
)
|
)
|
||||||
return None
|
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:
|
else:
|
||||||
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
logger.warning(f"Unsupported doc_type for context expansion: {doc_type}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for result in search_response.points:
|
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 can be int (notes) or str (files - file paths)
|
||||||
doc_id = result.payload["doc_id"]
|
doc_id = result.payload["doc_id"]
|
||||||
doc_type = result.payload.get("doc_type", "note")
|
doc_type = result.payload.get("doc_type", "note")
|
||||||
@@ -149,6 +151,17 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
|
|
||||||
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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)
|
# Return unverified results (verification happens at output stage)
|
||||||
results.append(
|
results.append(
|
||||||
SearchResult(
|
SearchResult(
|
||||||
@@ -157,10 +170,7 @@ class SemanticSearchAlgorithm(SearchAlgorithm):
|
|||||||
title=result.payload.get("title", "Untitled"),
|
title=result.payload.get("title", "Untitled"),
|
||||||
excerpt=result.payload.get("excerpt", ""),
|
excerpt=result.payload.get("excerpt", ""),
|
||||||
score=result.score,
|
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_start_offset=result.payload.get("chunk_start_offset"),
|
||||||
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
chunk_end_offset=result.payload.get("chunk_end_offset"),
|
||||||
page_number=result.payload.get("page_number"),
|
page_number=result.payload.get("page_number"),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from .calendar import configure_calendar_tools
|
|||||||
from .contacts import configure_contacts_tools
|
from .contacts import configure_contacts_tools
|
||||||
from .cookbook import configure_cookbook_tools
|
from .cookbook import configure_cookbook_tools
|
||||||
from .deck import configure_deck_tools
|
from .deck import configure_deck_tools
|
||||||
|
from .news import configure_news_tools
|
||||||
from .notes import configure_notes_tools
|
from .notes import configure_notes_tools
|
||||||
from .semantic import configure_semantic_tools
|
from .semantic import configure_semantic_tools
|
||||||
from .sharing import configure_sharing_tools
|
from .sharing import configure_sharing_tools
|
||||||
@@ -13,6 +14,7 @@ __all__ = [
|
|||||||
"configure_contacts_tools",
|
"configure_contacts_tools",
|
||||||
"configure_cookbook_tools",
|
"configure_cookbook_tools",
|
||||||
"configure_deck_tools",
|
"configure_deck_tools",
|
||||||
|
"configure_news_tools",
|
||||||
"configure_notes_tools",
|
"configure_notes_tools",
|
||||||
"configure_semantic_tools",
|
"configure_semantic_tools",
|
||||||
"configure_sharing_tools",
|
"configure_sharing_tools",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -19,7 +20,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_calendar_tools(mcp: FastMCP):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Calendars",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse:
|
||||||
@@ -30,7 +34,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
calendars = [Calendar(**cal_data) for cal_data in calendars_data]
|
||||||
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
return ListCalendarsResponse(calendars=calendars, total_count=len(calendars))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Calendar Event",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_event(
|
async def nc_calendar_create_event(
|
||||||
@@ -107,7 +114,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Calendar Events",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_events(
|
async def nc_calendar_list_events(
|
||||||
@@ -210,7 +220,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Calendar Event",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_event(
|
async def nc_calendar_get_event(
|
||||||
@@ -223,7 +236,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
event_data, etag = await client.calendar.get_event(calendar_name, event_uid)
|
||||||
return event_data
|
return event_data
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Calendar Event",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_event(
|
async def nc_calendar_update_event(
|
||||||
@@ -297,7 +313,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
calendar_name, event_uid, event_data, etag
|
calendar_name, event_uid, event_data, etag
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Calendar Event",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_event(
|
async def nc_calendar_delete_event(
|
||||||
@@ -309,7 +330,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.calendar.delete_event(calendar_name, event_uid)
|
return await client.calendar.delete_event(calendar_name, event_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Meeting",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_meeting(
|
async def nc_calendar_create_meeting(
|
||||||
@@ -376,7 +400,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_event(calendar_name, event_data)
|
return await client.calendar.create_event(calendar_name, event_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Upcoming Events",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_get_upcoming_events(
|
async def nc_calendar_get_upcoming_events(
|
||||||
@@ -427,7 +454,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||||
return all_events[:limit]
|
return all_events[:limit]
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Availability",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:read")
|
@require_scopes("calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_find_availability(
|
async def nc_calendar_find_availability(
|
||||||
@@ -508,7 +538,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
constraints=constraints,
|
constraints=constraints,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Bulk Calendar Operations",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_bulk_operations(
|
async def nc_calendar_bulk_operations(
|
||||||
@@ -758,7 +791,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
"results": results,
|
"results": results,
|
||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Manage Calendar",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("calendar:write")
|
@require_scopes("calendar:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_manage_calendar(
|
async def nc_calendar_manage_calendar(
|
||||||
@@ -828,7 +864,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# ============= Todo/Task Tools =============
|
# ============= Todo/Task Tools =============
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Todo Tasks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_list_todos(
|
async def nc_calendar_list_todos(
|
||||||
@@ -874,7 +913,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
todos=todos, calendar_name=calendar_name, total_count=len(todos)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Todo Task",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_create_todo(
|
async def nc_calendar_create_todo(
|
||||||
@@ -918,7 +960,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.create_todo(calendar_name, todo_data)
|
return await client.calendar.create_todo(calendar_name, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Todo Task",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_update_todo(
|
async def nc_calendar_update_todo(
|
||||||
@@ -979,7 +1024,12 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
return await client.calendar.update_todo(calendar_name, todo_uid, todo_data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Todo Task",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("todo:write", "calendar:read")
|
@require_scopes("todo:write", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_delete_todo(
|
async def nc_calendar_delete_todo(
|
||||||
@@ -1000,7 +1050,10 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
return await client.calendar.delete_todo(calendar_name, todo_uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Todo Tasks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("todo:read", "calendar:read")
|
@require_scopes("todo:read", "calendar:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_calendar_search_todos(
|
async def nc_calendar_search_todos(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Address Books",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_addressbooks(ctx: Context):
|
async def nc_contacts_list_addressbooks(ctx: Context):
|
||||||
@@ -19,7 +23,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
return await client.contacts.list_addressbooks()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Contacts",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:read")
|
@require_scopes("contacts:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str):
|
||||||
@@ -27,7 +34,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
return await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Address Book",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_addressbook(
|
async def nc_contacts_create_addressbook(
|
||||||
@@ -44,7 +54,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
name=name, display_name=display_name
|
name=name, display_name=display_name
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Address Book",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
async def nc_contacts_delete_addressbook(ctx: Context, *, name: str):
|
||||||
@@ -52,7 +67,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.delete_addressbook(name=name)
|
return await client.contacts.delete_addressbook(name=name)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Contact",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_create_contact(
|
async def nc_contacts_create_contact(
|
||||||
@@ -70,7 +88,12 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
addressbook=addressbook, uid=uid, contact_data=contact_data
|
addressbook=addressbook, uid=uid, contact_data=contact_data
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Contact",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str):
|
||||||
@@ -78,7 +101,10 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
return await client.contacts.delete_contact(addressbook=addressbook, uid=uid)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Contact",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("contacts:write")
|
@require_scopes("contacts:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_contacts_update_contact(
|
async def nc_contacts_update_contact(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, RequestError
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData, ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -71,7 +71,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Import Recipe from URL",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse:
|
||||||
@@ -129,7 +132,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipes",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse:
|
||||||
@@ -155,7 +161,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipe",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe:
|
||||||
@@ -181,7 +190,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Recipe",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_create_recipe(
|
async def nc_cookbook_create_recipe(
|
||||||
@@ -261,7 +273,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Recipe",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_update_recipe(
|
async def nc_cookbook_update_recipe(
|
||||||
@@ -351,7 +366,12 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Recipe",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_delete_recipe(
|
async def nc_cookbook_delete_recipe(
|
||||||
@@ -387,7 +407,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Recipes",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_search_recipes(
|
async def nc_cookbook_search_recipes(
|
||||||
@@ -424,7 +447,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipe Categories",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse:
|
||||||
@@ -452,7 +478,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipes in Category",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_in_category(
|
async def nc_cookbook_get_recipes_in_category(
|
||||||
@@ -489,7 +518,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Recipe Keywords",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse:
|
||||||
@@ -515,7 +547,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Recipes with Keywords",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:read")
|
@require_scopes("cookbook:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_get_recipes_with_keywords(
|
async def nc_cookbook_get_recipes_with_keywords(
|
||||||
@@ -550,7 +585,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Set Cookbook Configuration",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_set_config(
|
async def nc_cookbook_set_config(
|
||||||
@@ -594,7 +632,10 @@ def configure_cookbook_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Reindex Recipes",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("cookbook:write")
|
@require_scopes("cookbook:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -117,7 +118,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Read Tools (converted from resources)
|
# Read Tools (converted from resources)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Boards",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
async def deck_get_boards(ctx: Context) -> list[DeckBoard]:
|
||||||
@@ -126,7 +130,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return boards
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Board",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard:
|
||||||
@@ -135,7 +142,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board
|
return board
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Stacks",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]:
|
||||||
@@ -144,7 +154,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return stacks
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Stack",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack:
|
||||||
@@ -153,7 +166,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
return stack
|
return stack
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Cards",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
@@ -166,7 +182,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
return stack.cards
|
return stack.cards
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Card",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_card(
|
async def deck_get_card(
|
||||||
@@ -177,7 +196,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
card = await client.deck.get_card(board_id, stack_id, card_id)
|
card = await client.deck.get_card(board_id, stack_id, card_id)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Deck Labels",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]:
|
||||||
@@ -186,7 +208,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
return board.labels
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Deck Label",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:read")
|
@require_scopes("deck:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel:
|
||||||
@@ -197,7 +222,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Create/Update/Delete Tools
|
# Create/Update/Delete Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Board",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_board(
|
async def deck_create_board(
|
||||||
@@ -215,7 +243,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Stack Tools
|
# Stack Tools
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Stack",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_stack(
|
async def deck_create_stack(
|
||||||
@@ -232,7 +263,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stack = await client.deck.create_stack(board_id, title, order)
|
stack = await client.deck.create_stack(board_id, title, order)
|
||||||
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Stack",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_stack(
|
async def deck_update_stack(
|
||||||
@@ -259,7 +293,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Stack",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_stack(
|
async def deck_delete_stack(
|
||||||
@@ -281,7 +320,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card Tools
|
# Card Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_card(
|
async def deck_create_card(
|
||||||
@@ -316,7 +358,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
stackId=card.stackId,
|
stackId=card.stackId,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_card(
|
async def deck_update_card(
|
||||||
@@ -370,7 +415,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Card",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_card(
|
async def deck_delete_card(
|
||||||
@@ -393,7 +443,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Archive Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_archive_card(
|
async def deck_archive_card(
|
||||||
@@ -416,7 +469,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Unarchive Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unarchive_card(
|
async def deck_unarchive_card(
|
||||||
@@ -439,7 +495,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Reorder/Move Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_reorder_card(
|
async def deck_reorder_card(
|
||||||
@@ -472,7 +531,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Label Tools
|
# Label Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Deck Label",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_create_label(
|
async def deck_create_label(
|
||||||
@@ -489,7 +551,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
label = await client.deck.create_label(board_id, title, color)
|
label = await client.deck.create_label(board_id, title, color)
|
||||||
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
return CreateLabelResponse(id=label.id, title=label.title, color=label.color)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Deck Label",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_update_label(
|
async def deck_update_label(
|
||||||
@@ -516,7 +581,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Deck Label",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_delete_label(
|
async def deck_delete_label(
|
||||||
@@ -538,7 +608,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card-Label Assignment Tools
|
# Card-Label Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Assign Label to Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_label_to_card(
|
async def deck_assign_label_to_card(
|
||||||
@@ -562,7 +635,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Remove Label from Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_remove_label_from_card(
|
async def deck_remove_label_from_card(
|
||||||
@@ -587,7 +663,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Card-User Assignment Tools
|
# Card-User Assignment Tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Assign User to Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_assign_user_to_card(
|
async def deck_assign_user_to_card(
|
||||||
@@ -611,7 +690,10 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
board_id=board_id,
|
board_id=board_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Unassign User from Deck Card",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("deck:write")
|
@require_scopes("deck:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_unassign_user_from_card(
|
async def deck_unassign_user_from_card(
|
||||||
|
|||||||
@@ -0,0 +1,384 @@
|
|||||||
|
"""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}",
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -3,7 +3,7 @@ import logging
|
|||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, RequestError
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.shared.exceptions import McpError
|
from mcp.shared.exceptions import McpError
|
||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData, ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -85,7 +85,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Multiple calls create multiple notes
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_create_note(
|
async def nc_notes_create_note(
|
||||||
@@ -132,7 +138,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Requires etag which changes = not idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_update_note(
|
async def nc_notes_update_note(
|
||||||
@@ -198,7 +210,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Append to Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Each call adds content = not idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_append_content(
|
async def nc_notes_append_content(
|
||||||
@@ -249,7 +267,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Notes",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||||
@@ -296,7 +320,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Read operation only
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
async def nc_notes_get_note(note_id: int, ctx: Context) -> Note:
|
||||||
@@ -326,7 +356,13 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Note Attachment",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Read operation only
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:read")
|
@require_scopes("notes:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_get_attachment(
|
async def nc_notes_get_attachment(
|
||||||
@@ -373,7 +409,14 @@ def configure_notes_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Note",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Permanently deletes data
|
||||||
|
idempotentHint=True, # Deleting deleted note = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("notes:write")
|
@require_scopes("notes:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import httpx
|
|||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
@@ -684,11 +685,16 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="provision_nextcloud_access",
|
name="provision_nextcloud_access",
|
||||||
|
title="Grant Server Access to Nextcloud",
|
||||||
description=(
|
description=(
|
||||||
"Provision offline access to Nextcloud resources. "
|
"Provision offline access to Nextcloud resources. "
|
||||||
"This is required before using Nextcloud tools. "
|
"This is required before using Nextcloud tools. "
|
||||||
"You'll need to complete an OAuth authorization in your browser."
|
"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")
|
@require_scopes("openid")
|
||||||
async def tool_provision_access(
|
async def tool_provision_access(
|
||||||
@@ -699,7 +705,13 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="revoke_nextcloud_access",
|
name="revoke_nextcloud_access",
|
||||||
|
title="Revoke Server Access to Nextcloud",
|
||||||
description="Revoke offline access to Nextcloud resources.",
|
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")
|
@require_scopes("openid")
|
||||||
async def tool_revoke_access(
|
async def tool_revoke_access(
|
||||||
@@ -709,7 +721,12 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="check_provisioning_status",
|
name="check_provisioning_status",
|
||||||
|
title="Check Provisioning Status",
|
||||||
description="Check whether Nextcloud access is provisioned.",
|
description="Check whether Nextcloud access is provisioned.",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Only checks status, doesn't modify
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
@require_scopes("openid")
|
@require_scopes("openid")
|
||||||
async def tool_check_status(
|
async def tool_check_status(
|
||||||
@@ -719,10 +736,15 @@ def register_oauth_tools(mcp):
|
|||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
name="check_logged_in",
|
name="check_logged_in",
|
||||||
|
title="Check Server Login Status",
|
||||||
description=(
|
description=(
|
||||||
"Check if you are logged in to Nextcloud. "
|
"Check if you are logged in to Nextcloud. "
|
||||||
"If not logged in, this tool will prompt you to complete the login flow."
|
"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")
|
@require_scopes("openid")
|
||||||
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
async def tool_check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from mcp.types import (
|
|||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
SamplingMessage,
|
SamplingMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
|
ToolAnnotations,
|
||||||
)
|
)
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
@@ -34,7 +35,13 @@ logger = logging.getLogger(__name__)
|
|||||||
def configure_semantic_tools(mcp: FastMCP):
|
def configure_semantic_tools(mcp: FastMCP):
|
||||||
"""Configure semantic search tools for MCP server."""
|
"""Configure semantic search tools for MCP server."""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Semantic Search",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=True, # Queries external Nextcloud service
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search(
|
async def nc_semantic_search(
|
||||||
@@ -58,13 +65,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
database for optimal relevance. This provides the best of both semantic
|
database for optimal relevance. This provides the best of both semantic
|
||||||
understanding and keyword precision.
|
understanding and keyword precision.
|
||||||
|
|
||||||
Requires VECTOR_SYNC_ENABLED=true. Currently only "note" documents are
|
Requires VECTOR_SYNC_ENABLED=true. Supports indexing of notes, files,
|
||||||
fully supported for indexing.
|
news items, and deck cards.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: Natural language or keyword search query
|
query: Natural language or keyword search query
|
||||||
limit: Maximum number of results to return (default: 10)
|
limit: Maximum number of results to return (default: 10)
|
||||||
doc_types: Document types to search (e.g., ["note", "file"]). None = search all indexed types (default)
|
doc_types: Document types to search (e.g., ["note", "file", "deck_card", "news_item"]). None = search all indexed types (default)
|
||||||
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
score_threshold: Minimum fusion score (0-1, default: 0.0)
|
||||||
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
fusion: Fusion algorithm: "rrf" (Reciprocal Rank Fusion, default) or "dbsf" (Distribution-Based Score Fusion)
|
||||||
RRF: Good general-purpose fusion using reciprocal ranks
|
RRF: Good general-purpose fusion using reciprocal ranks
|
||||||
@@ -285,7 +292,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
logger.error(f"Search error: {e}", exc_info=True)
|
logger.error(f"Search error: {e}", exc_info=True)
|
||||||
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
raise McpError(ErrorData(code=-1, message=f"Search failed: {str(e)}"))
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search with AI-Generated Answer",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Search doesn't modify data
|
||||||
|
openWorldHint=False, # Searches only indexed Nextcloud data
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_semantic_search_answer(
|
async def nc_semantic_search_answer(
|
||||||
@@ -499,9 +512,11 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. Request LLM completion via MCP sampling with timeout
|
# 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:
|
try:
|
||||||
with anyio.fail_after(30):
|
with anyio.fail_after(sampling_timeout_seconds):
|
||||||
sampling_result = await ctx.session.create_message(
|
sampling_result = await ctx.session.create_message(
|
||||||
messages=[
|
messages=[
|
||||||
SamplingMessage(
|
SamplingMessage(
|
||||||
@@ -548,14 +563,14 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Sampling request timed out after 30 seconds for query: '{query}', "
|
f"Sampling request timed out after {sampling_timeout_seconds} seconds for query: '{query}', "
|
||||||
f"returning search results only"
|
f"returning search results only"
|
||||||
)
|
)
|
||||||
return SamplingSearchResponse(
|
return SamplingSearchResponse(
|
||||||
query=query,
|
query=query,
|
||||||
generated_answer=(
|
generated_answer=(
|
||||||
f"[Sampling request timed out]\n\n"
|
f"[Sampling request timed out]\n\n"
|
||||||
f"The answer generation took too long (>30s). "
|
f"The answer generation took too long (>{sampling_timeout_seconds}s). "
|
||||||
f"Found {len(accessible_results)} relevant documents. "
|
f"Found {len(accessible_results)} relevant documents. "
|
||||||
f"Please review the sources below or try a simpler query."
|
f"Please review the sources below or try a simpler query."
|
||||||
),
|
),
|
||||||
@@ -621,7 +636,13 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
success=True,
|
success=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Check Indexing Status",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True, # Only checks status
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("semantic:read")
|
@require_scopes("semantic:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
async def nc_get_vector_sync_status(ctx: Context) -> VectorSyncStatusResponse:
|
||||||
@@ -675,15 +696,22 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
|
from qdrant_client.models import Filter
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
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
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection
|
# Count documents in collection, excluding placeholders
|
||||||
|
# Placeholders are zero-vector points used to track processing state
|
||||||
count_result = await qdrant_client.count(
|
count_result = await qdrant_client.count(
|
||||||
collection_name=settings.get_collection_name()
|
collection_name=settings.get_collection_name(),
|
||||||
|
count_filter=Filter(must=[get_placeholder_filter()]),
|
||||||
)
|
)
|
||||||
indexed_count = count_result.count
|
indexed_count = count_result.count
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -16,7 +17,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
mcp: FastMCP server instance
|
mcp: FastMCP server instance
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Share",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_create(
|
async def nc_share_create(
|
||||||
@@ -56,7 +60,12 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Share",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
async def nc_share_delete(share_id: int, ctx: Context) -> str:
|
||||||
@@ -76,7 +85,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
{"success": True, "message": f"Share {share_id} deleted"}, indent=2
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Share Details",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
async def nc_share_get(share_id: int, ctx: Context) -> str:
|
||||||
@@ -95,7 +107,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
share_data = await client.sharing.get_share(share_id)
|
share_data = await client.sharing.get_share(share_id)
|
||||||
return json.dumps(share_data, indent=2)
|
return json.dumps(share_data, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Shares",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_list(
|
async def nc_share_list(
|
||||||
@@ -117,7 +132,10 @@ def configure_sharing_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
return json.dumps(shares, indent=2)
|
return json.dumps(shares, indent=2)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Share",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("sharing:write")
|
@require_scopes("sharing:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -11,7 +12,10 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_tables_tools(mcp: FastMCP):
|
def configure_tables_tools(mcp: FastMCP):
|
||||||
# Tables tools
|
# Tables tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Tables",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_list_tables(ctx: Context):
|
async def nc_tables_list_tables(ctx: Context):
|
||||||
@@ -19,7 +23,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
return await client.tables.list_tables()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Get Table Schema",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
async def nc_tables_get_schema(table_id: int, ctx: Context):
|
||||||
@@ -27,7 +34,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.get_table_schema(table_id)
|
return await client.tables.get_table_schema(table_id)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Read Table Rows",
|
||||||
|
annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:read")
|
@require_scopes("tables:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_read_table(
|
async def nc_tables_read_table(
|
||||||
@@ -40,7 +50,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.get_table_rows(table_id, limit, offset)
|
return await client.tables.get_table_rows(table_id, limit, offset)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Insert Table Row",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context):
|
||||||
@@ -51,7 +64,10 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.create_row(table_id, data)
|
return await client.tables.create_row(table_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Update Table Row",
|
||||||
|
annotations=ToolAnnotations(idempotentHint=False, openWorldHint=True),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
async def nc_tables_update_row(row_id: int, data: dict, ctx: Context):
|
||||||
@@ -62,7 +78,12 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.update_row(row_id, data)
|
return await client.tables.update_row(row_id, data)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete Table Row",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, idempotentHint=True, openWorldHint=True
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("tables:write")
|
@require_scopes("tables:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
async def nc_tables_delete_row(row_id: int, ctx: Context):
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
|
from mcp.types import ToolAnnotations
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
@@ -16,7 +17,13 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
def configure_webdav_tools(mcp: FastMCP):
|
def configure_webdav_tools(mcp: FastMCP):
|
||||||
# WebDAV file system tools
|
# WebDAV file system tools
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Files and Directories",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_directory(
|
async def nc_webdav_list_directory(
|
||||||
@@ -50,7 +57,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
total_size=total_size,
|
total_size=total_size,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Read File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_read_file(path: str, ctx: Context):
|
async def nc_webdav_read_file(path: str, ctx: Context):
|
||||||
@@ -117,7 +130,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
"encoding": "base64",
|
"encoding": "base64",
|
||||||
}
|
}
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Write File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=True, # HTTP PUT without version control is idempotent
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_write_file(
|
async def nc_webdav_write_file(
|
||||||
@@ -146,7 +165,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
return await client.webdav.write_file(path, content_bytes, content_type)
|
return await client.webdav.write_file(path, content_bytes, content_type)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Create Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=True, # Creating existing dir returns 405 = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_create_directory(path: str, ctx: Context):
|
async def nc_webdav_create_directory(path: str, ctx: Context):
|
||||||
@@ -161,7 +186,14 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.webdav.create_directory(path)
|
return await client.webdav.create_directory(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Delete File or Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
destructiveHint=True, # Permanently deletes data
|
||||||
|
idempotentHint=True, # Deleting deleted resource = same end state
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
async def nc_webdav_delete_resource(path: str, ctx: Context):
|
||||||
@@ -176,7 +208,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.webdav.delete_resource(path)
|
return await client.webdav.delete_resource(path)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Move or Rename File",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Moving changes source and dest
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_move_resource(
|
async def nc_webdav_move_resource(
|
||||||
@@ -197,7 +235,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Copy File or Directory",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
idempotentHint=False, # Creates new resource each time
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:write")
|
@require_scopes("files:write")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_copy_resource(
|
async def nc_webdav_copy_resource(
|
||||||
@@ -218,7 +262,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
source_path, destination_path, overwrite
|
source_path, destination_path, overwrite
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Search Files",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_search_files(
|
async def nc_webdav_search_files(
|
||||||
@@ -335,7 +385,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied=filters if filters else None,
|
filters_applied=filters if filters else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Files by Name",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_name(
|
async def nc_webdav_find_by_name(
|
||||||
@@ -363,7 +419,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied={"name_pattern": pattern},
|
filters_applied={"name_pattern": pattern},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="Find Files by Type",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_find_by_type(
|
async def nc_webdav_find_by_type(
|
||||||
@@ -391,7 +453,13 @@ def configure_webdav_tools(mcp: FastMCP):
|
|||||||
filters_applied={"mime_type": mime_type},
|
filters_applied={"mime_type": mime_type},
|
||||||
)
|
)
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool(
|
||||||
|
title="List Favorite Files",
|
||||||
|
annotations=ToolAnnotations(
|
||||||
|
readOnlyHint=True,
|
||||||
|
openWorldHint=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
@require_scopes("files:read")
|
@require_scopes("files:read")
|
||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def nc_webdav_list_favorites(
|
async def nc_webdav_list_favorites(
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""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,6 +6,7 @@ Processes documents from stream: fetches content, generates embeddings, stores i
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
@@ -272,6 +273,136 @@ async def _index_document(
|
|||||||
file_path = None # Notes don't have file paths
|
file_path = None # Notes don't have file paths
|
||||||
content_bytes = None # Notes don't have binary content
|
content_bytes = None # Notes don't have binary content
|
||||||
content_type = None
|
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":
|
elif doc_task.doc_type == "file":
|
||||||
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
# For files, doc_id is now the numeric file ID, file_path comes from DocumentTask
|
||||||
if not doc_task.file_path:
|
if not doc_task.file_path:
|
||||||
@@ -358,15 +489,18 @@ async def _index_document(
|
|||||||
chunks = await chunker.chunk_text(content)
|
chunks = await chunker.chunk_text(content)
|
||||||
|
|
||||||
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
# Assign page numbers to chunks if page boundaries are available (PDFs)
|
||||||
if doc_task.doc_type == "file" and "page_boundaries" in file_metadata:
|
page_boundaries = file_metadata.get("page_boundaries")
|
||||||
|
if doc_task.doc_type == "file" and page_boundaries is not None:
|
||||||
|
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||||
|
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||||
with trace_operation(
|
with trace_operation(
|
||||||
"vector_sync.assign_page_numbers",
|
"vector_sync.assign_page_numbers",
|
||||||
attributes={
|
attributes={
|
||||||
"vector_sync.chunk_count": len(chunks),
|
"vector_sync.chunk_count": len(chunks),
|
||||||
"vector_sync.page_count": len(file_metadata["page_boundaries"]),
|
"vector_sync.page_count": len(page_boundaries_list),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
assign_page_numbers(chunks, file_metadata["page_boundaries"])
|
assign_page_numbers(chunks, page_boundaries_list)
|
||||||
|
|
||||||
# Diagnostic: Verify page number assignment
|
# Diagnostic: Verify page number assignment
|
||||||
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
assigned_count = sum(1 for c in chunks if c.page_number is not None)
|
||||||
@@ -389,8 +523,8 @@ async def _index_document(
|
|||||||
f"Text length: {len(content)}, "
|
f"Text length: {len(content)}, "
|
||||||
f"Chunks: {len(chunks)}, "
|
f"Chunks: {len(chunks)}, "
|
||||||
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
f"Chunk offset range: [{chunks[0].start_offset}:{chunks[-1].end_offset}], "
|
||||||
f"Page boundaries: {len(file_metadata['page_boundaries'])} pages, "
|
f"Page boundaries: {len(page_boundaries_list)} pages, "
|
||||||
f"First boundary: {file_metadata['page_boundaries'][0] if file_metadata['page_boundaries'] else 'None'}"
|
f"First boundary: {page_boundaries_list[0] if page_boundaries_list else 'None'}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Extract chunk texts for embedding
|
# Extract chunk texts for embedding
|
||||||
@@ -464,6 +598,9 @@ async def _index_document(
|
|||||||
logger.warning("No page boundaries available, skipping highlighting")
|
logger.warning("No page boundaries available, skipping highlighting")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Type narrowing: page_boundaries is guaranteed to be list[dict] here
|
||||||
|
page_boundaries_list = cast(list[dict[str, Any]], page_boundaries)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
f"Batch generating highlighted page images for {len(chunk_data)} PDF chunks"
|
||||||
)
|
)
|
||||||
@@ -474,7 +611,7 @@ async def _index_document(
|
|||||||
lambda: PDFHighlighter.highlight_chunks_batch(
|
lambda: PDFHighlighter.highlight_chunks_batch(
|
||||||
pdf_bytes=content_bytes,
|
pdf_bytes=content_bytes,
|
||||||
chunks=chunk_data,
|
chunks=chunk_data,
|
||||||
page_boundaries=page_boundaries,
|
page_boundaries=page_boundaries_list,
|
||||||
full_text=content,
|
full_text=content,
|
||||||
color="yellow",
|
color="yellow",
|
||||||
zoom=2.0,
|
zoom=2.0,
|
||||||
@@ -566,6 +703,37 @@ async def _index_document(
|
|||||||
if doc_task.doc_type == "file"
|
if doc_task.doc_type == "file"
|
||||||
else {}
|
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)
|
# Highlighted page image (PDF only)
|
||||||
**(
|
**(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class DocumentTask:
|
|||||||
operation: str # "index" or "delete"
|
operation: str # "index" or "delete"
|
||||||
modified_at: int
|
modified_at: int
|
||||||
file_path: str | None = None # File path for files (when doc_id is file_id)
|
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)
|
# Track documents potentially deleted (grace period before actual deletion)
|
||||||
@@ -79,9 +82,11 @@ async def get_last_indexed_timestamp(user_id: str) -> int | None:
|
|||||||
|
|
||||||
if scroll_result[0]:
|
if scroll_result[0]:
|
||||||
timestamps = [
|
timestamps = [
|
||||||
point.payload.get("indexed_at", 0) for point in scroll_result[0]
|
point.payload.get("indexed_at", 0)
|
||||||
|
for point in scroll_result[0]
|
||||||
|
if point.payload is not None
|
||||||
]
|
]
|
||||||
max_timestamp = max(timestamps)
|
max_timestamp = max(timestamps) if timestamps else 0
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
|
f"Max indexed_at: {max_timestamp}, timestamps sample: {timestamps[:3]}"
|
||||||
)
|
)
|
||||||
@@ -206,7 +211,11 @@ async def scan_user_documents(
|
|||||||
limit=10000,
|
limit=10000,
|
||||||
)
|
)
|
||||||
|
|
||||||
indexed_doc_ids = {point.payload["doc_id"] for point in scroll_result[0]}
|
indexed_doc_ids = {
|
||||||
|
point.payload["doc_id"]
|
||||||
|
for point in (scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
logger.debug(f"Found {len(indexed_doc_ids)} indexed documents in Qdrant")
|
||||||
|
|
||||||
@@ -376,7 +385,9 @@ async def scan_user_documents(
|
|||||||
)
|
)
|
||||||
|
|
||||||
indexed_file_ids = {
|
indexed_file_ids = {
|
||||||
point.payload["doc_id"] for point in file_scroll_result[0]
|
point.payload["doc_id"]
|
||||||
|
for point in (file_scroll_result[0] or [])
|
||||||
|
if point.payload is not None
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
logger.debug(f"Found {len(indexed_file_ids)} indexed files in Qdrant")
|
||||||
@@ -544,9 +555,419 @@ async def scan_user_documents(
|
|||||||
|
|
||||||
queued += file_queued
|
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:
|
if queued > 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Sent {queued} documents ({file_queued} files) for incremental sync: {user_id}"
|
f"Sent {queued} documents ({file_queued} files, {news_queued} news items, {deck_queued} deck cards) for incremental sync: {user_id}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No changes detected for {user_id}")
|
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
|
||||||
|
|||||||
+4
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.48.2"
|
version = "0.52.0"
|
||||||
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
{name = "Chris Coutinho", email = "chris@coutinho.io"}
|
||||||
@@ -10,7 +10,7 @@ license = {text = "AGPL-3.0-only"}
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
keywords = ["nextcloud", "mcp", "model-context-protocol", "llm", "ai", "claude", "webdav", "caldav", "carddav"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"mcp[cli] (>=1.22,<1.23)",
|
"mcp[cli] (>=1.23,<1.24)",
|
||||||
"httpx (>=0.28.1,<0.29.0)",
|
"httpx (>=0.28.1,<0.29.0)",
|
||||||
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
"pillow (>=10.3.0,<12.0.0)", # Compatible with fastembed
|
||||||
"icalendar (>=6.0.0,<7.0.0)",
|
"icalendar (>=6.0.0,<7.0.0)",
|
||||||
@@ -36,6 +36,7 @@ dependencies = [
|
|||||||
"python-json-logger>=3.2.0", # Structured JSON logging
|
"python-json-logger>=3.2.0", # Structured JSON logging
|
||||||
"jinja2>=3.1.6",
|
"jinja2>=3.1.6",
|
||||||
"langchain-text-splitters>=1.0.0",
|
"langchain-text-splitters>=1.0.0",
|
||||||
|
"markdownify>=0.14.1", # HTML to Markdown conversion for News items
|
||||||
"pymupdf>=1.26.6",
|
"pymupdf>=1.26.6",
|
||||||
"pymupdf4llm>=0.2.2",
|
"pymupdf4llm>=0.2.2",
|
||||||
"pymupdf-layout>=1.26.6",
|
"pymupdf-layout>=1.26.6",
|
||||||
@@ -100,6 +101,7 @@ extend-select = ["I"]
|
|||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
caldav = { git = "https://github.com/cbcoutinho/caldav", branch = "feature/httpx" }
|
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]
|
[build-system]
|
||||||
requires = ["uv_build>=0.9.4,<0.10.0"]
|
requires = ["uv_build>=0.9.4,<0.10.0"]
|
||||||
|
|||||||
+7
-1
@@ -4,5 +4,11 @@
|
|||||||
"config:best-practices",
|
"config:best-practices",
|
||||||
"mergeConfidence:all-badges"
|
"mergeConfidence:all-badges"
|
||||||
],
|
],
|
||||||
"dependencyDashboard": true
|
"dependencyDashboard": true,
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchPackageNames": ["pillow"],
|
||||||
|
"allowedVersions": "<12.0.0"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -480,3 +480,222 @@ def create_mock_table_row_ocs_response(
|
|||||||
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
ocs_response = {"ocs": {"meta": {"status": "ok"}, "data": row_data}}
|
||||||
|
|
||||||
return create_mock_response(status_code=200, json_data=ocs_response)
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,580 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""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)"
|
||||||
|
)
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"""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()
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""MCP sampling support for integration tests.
|
"""MCP sampling support for integration tests.
|
||||||
|
|
||||||
This module provides utilities to enable real LLM-based sampling in integration tests
|
This module provides utilities to enable real LLM-based sampling in integration tests
|
||||||
using OpenAI or GitHub Models API.
|
using any provider that supports text generation (OpenAI, Ollama, Anthropic, Bedrock).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -10,46 +10,58 @@ from typing import Any
|
|||||||
from mcp import types
|
from mcp import types
|
||||||
from mcp.client.session import ClientSession, RequestContext
|
from mcp.client.session import ClientSession, RequestContext
|
||||||
|
|
||||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
from nextcloud_mcp_server.providers.base import Provider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def create_openai_sampling_callback(provider: OpenAIProvider):
|
def create_sampling_callback(provider: Provider):
|
||||||
"""Factory to create a sampling callback using OpenAI provider.
|
"""Factory to create a sampling callback using any generation-capable provider.
|
||||||
|
|
||||||
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
The callback conforms to MCP's SamplingFnT protocol and can be passed
|
||||||
to ClientSession for handling sampling requests from the server.
|
to ClientSession for handling sampling requests from the server.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: OpenAIProvider instance configured with a generation model
|
provider: Any Provider instance that supports generation
|
||||||
|
(supports_generation=True)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Async callback function for MCP sampling
|
Async callback function for MCP sampling
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider doesn't support generation
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
provider = OpenAIProvider(
|
from nextcloud_mcp_server.providers import get_provider
|
||||||
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)
|
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
provider = get_provider() # Auto-detect from environment
|
||||||
url="http://localhost:8000/mcp",
|
if provider.supports_generation:
|
||||||
sampling_callback=callback,
|
callback = create_sampling_callback(provider)
|
||||||
):
|
|
||||||
# Session now supports sampling
|
async for session in create_mcp_client_session(
|
||||||
pass
|
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(
|
async def sampling_callback(
|
||||||
context: RequestContext[ClientSession, Any],
|
context: RequestContext[ClientSession, Any],
|
||||||
params: types.CreateMessageRequestParams,
|
params: types.CreateMessageRequestParams,
|
||||||
) -> types.CreateMessageResult | types.ErrorData:
|
) -> types.CreateMessageResult | types.ErrorData:
|
||||||
"""Handle sampling requests using OpenAI provider."""
|
"""Handle sampling requests using the configured provider."""
|
||||||
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
logger.debug(f"Sampling callback invoked with {len(params.messages)} messages")
|
||||||
|
|
||||||
# Extract messages and build prompt
|
# Extract messages and build prompt
|
||||||
@@ -68,14 +80,13 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
|
|||||||
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
logger.debug(f"Generating response for prompt ({len(prompt)} chars)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Generate response using OpenAI provider
|
# Generate response using provider
|
||||||
# Note: temperature is hardcoded in the provider at 0.7
|
# Note: temperature is typically hardcoded in providers at 0.7
|
||||||
response = await provider.generate(
|
response = await provider.generate(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
max_tokens=params.maxTokens,
|
max_tokens=params.maxTokens,
|
||||||
)
|
)
|
||||||
|
|
||||||
model_name = provider.generation_model or "unknown"
|
|
||||||
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
logger.info(f"Sampling completed: {len(response)} chars from {model_name}")
|
||||||
|
|
||||||
return types.CreateMessageResult(
|
return types.CreateMessageResult(
|
||||||
@@ -85,10 +96,25 @@ def create_openai_sampling_callback(provider: OpenAIProvider):
|
|||||||
stopReason="endTurn",
|
stopReason="endTurn",
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"OpenAI generation failed: {e}")
|
logger.error(f"Generation failed ({provider.__class__.__name__}): {e}")
|
||||||
return types.ErrorData(
|
return types.ErrorData(
|
||||||
code=types.INTERNAL_ERROR,
|
code=types.INTERNAL_ERROR,
|
||||||
message=f"OpenAI generation failed: {e!s}",
|
message=f"Generation failed: {e!s}",
|
||||||
)
|
)
|
||||||
|
|
||||||
return sampling_callback
|
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)
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
"""Integration tests for Deck card vector search.
|
||||||
|
|
||||||
|
These tests validate that Deck cards are properly indexed and searchable
|
||||||
|
via semantic search.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [pytest.mark.integration, pytest.mark.smoke]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_card_semantic_search(nc_mcp_client, nc_client, mocker):
|
||||||
|
"""Test that Deck cards can be indexed and searched via semantic search.
|
||||||
|
|
||||||
|
This test:
|
||||||
|
1. Creates a Deck board with a card
|
||||||
|
2. Manually triggers indexing (simulates vector sync)
|
||||||
|
3. Performs semantic search filtering by deck_card doc_type
|
||||||
|
4. Verifies the card is found in results
|
||||||
|
"""
|
||||||
|
# Skip if vector sync is not enabled
|
||||||
|
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||||
|
if settings_response.isError:
|
||||||
|
pytest.skip("Vector sync not enabled")
|
||||||
|
|
||||||
|
# Create a test board
|
||||||
|
board_title = "Test Board for Vector Search"
|
||||||
|
board = await nc_client.deck.create_board(title=board_title, color="ff0000")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a stack for the board
|
||||||
|
stack = await nc_client.deck.create_stack(
|
||||||
|
board_id=board.id, title="Test Stack", order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a test card with searchable content
|
||||||
|
card_title = "Machine Learning Project Plan"
|
||||||
|
card_description = """
|
||||||
|
# ML Project Outline
|
||||||
|
|
||||||
|
## Phase 1: Data Collection
|
||||||
|
- Gather training data from multiple sources
|
||||||
|
- Clean and preprocess the dataset
|
||||||
|
|
||||||
|
## Phase 2: Model Training
|
||||||
|
- Experiment with different neural network architectures
|
||||||
|
- Use gradient descent optimization
|
||||||
|
|
||||||
|
## Phase 3: Deployment
|
||||||
|
- Deploy model to production environment
|
||||||
|
- Monitor performance metrics
|
||||||
|
"""
|
||||||
|
card = await nc_client.deck.create_card(
|
||||||
|
board_id=board.id,
|
||||||
|
stack_id=stack.id,
|
||||||
|
title=card_title,
|
||||||
|
description=card_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Note: In a real integration test with vector sync enabled,
|
||||||
|
# we would wait for the background scanner to index the card.
|
||||||
|
# For now, we'll test the scanning function directly if needed.
|
||||||
|
|
||||||
|
# TODO: Once vector sync is running in test environment,
|
||||||
|
# add actual semantic search test here
|
||||||
|
# For now, just verify the card was created successfully
|
||||||
|
assert card.id is not None
|
||||||
|
assert card.title == card_title
|
||||||
|
assert card.description == card_description
|
||||||
|
|
||||||
|
# Test semantic search with deck_card filter
|
||||||
|
# Note: This will only work if vector sync is actually running
|
||||||
|
# and the card has been indexed
|
||||||
|
try:
|
||||||
|
search_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_semantic_search",
|
||||||
|
{
|
||||||
|
"query": "machine learning neural networks",
|
||||||
|
"doc_types": ["deck_card"],
|
||||||
|
"limit": 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# If vector sync is working, we should find the card
|
||||||
|
if not search_result.isError:
|
||||||
|
data = search_result.structuredContent
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
# Check if our card is in the results
|
||||||
|
found_card = any(
|
||||||
|
r.get("doc_type") == "deck_card" and r.get("title") == card_title
|
||||||
|
for r in results
|
||||||
|
)
|
||||||
|
|
||||||
|
# Log result for debugging
|
||||||
|
if found_card:
|
||||||
|
print("✓ Successfully found Deck card in vector search")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"⚠ Deck card not found in search (may need time for indexing)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
# If search fails, it might be because indexing hasn't happened yet
|
||||||
|
print(f"⚠ Semantic search failed (indexing may not be complete): {e}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup: delete the board
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_board(board.id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to cleanup test board: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_card_appears_in_cross_app_search(nc_mcp_client, nc_client):
|
||||||
|
"""Test that Deck cards appear in cross-app semantic search (no doc_type filter).
|
||||||
|
|
||||||
|
This verifies that when searching without specifying doc_types,
|
||||||
|
Deck cards are included in the results alongside notes, files, etc.
|
||||||
|
"""
|
||||||
|
# Skip if vector sync is not enabled
|
||||||
|
settings_response = await nc_mcp_client.call_tool("nc_get_vector_sync_status", {})
|
||||||
|
if settings_response.isError:
|
||||||
|
pytest.skip("Vector sync not enabled")
|
||||||
|
|
||||||
|
# Create a test board with a distinctive card
|
||||||
|
board_title = "Cross-App Search Test Board"
|
||||||
|
board = await nc_client.deck.create_board(title=board_title, color="00ff00")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create a stack for the board
|
||||||
|
stack = await nc_client.deck.create_stack(
|
||||||
|
board_id=board.id, title="Test Stack", order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use a very distinctive term to make it easy to find
|
||||||
|
unique_term = "xylophone_banana_unicorn_test"
|
||||||
|
_card = await nc_client.deck.create_card(
|
||||||
|
board_id=board.id,
|
||||||
|
stack_id=stack.id,
|
||||||
|
title=f"Test Card with {unique_term}",
|
||||||
|
description=f"This card contains the unique search term: {unique_term}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test cross-app search (no doc_type filter)
|
||||||
|
try:
|
||||||
|
search_result = await nc_mcp_client.call_tool(
|
||||||
|
"nc_semantic_search",
|
||||||
|
{
|
||||||
|
"query": unique_term,
|
||||||
|
"limit": 20,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not search_result.isError:
|
||||||
|
data = search_result.structuredContent
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
# Check if deck_card appears in cross-app results
|
||||||
|
deck_cards_found = [
|
||||||
|
r for r in results if r.get("doc_type") == "deck_card"
|
||||||
|
]
|
||||||
|
|
||||||
|
if deck_cards_found:
|
||||||
|
print(
|
||||||
|
f"✓ Found {len(deck_cards_found)} Deck card(s) in cross-app search"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
"⚠ No Deck cards in cross-app search (may need time for indexing)"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠ Cross-app search failed: {e}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_board(board.id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to cleanup test board: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_deck_card_chunk_context(nc_client):
|
||||||
|
"""Test that Deck card chunk context can be fetched for visualization.
|
||||||
|
|
||||||
|
This test validates that the vector viz UI can display Deck card previews
|
||||||
|
by fetching the chunk context via the context expansion module.
|
||||||
|
"""
|
||||||
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
|
||||||
|
# Create board, stack, and card
|
||||||
|
board = await nc_client.deck.create_board(title="Test Board", color="ff0000")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stack = await nc_client.deck.create_stack(
|
||||||
|
board_id=board.id, title="Test Stack", order=0
|
||||||
|
)
|
||||||
|
|
||||||
|
card_title = "Test Card for Context Expansion"
|
||||||
|
card_description = "This is a test description that should be fetched by the context expansion module when displaying chunk previews in the vector visualization UI."
|
||||||
|
|
||||||
|
card = await nc_client.deck.create_card(
|
||||||
|
board_id=board.id,
|
||||||
|
stack_id=stack.id,
|
||||||
|
title=card_title,
|
||||||
|
description=card_description,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch chunk context (simulates viz UI request)
|
||||||
|
# The chunk spans the title, so start=0 and end=len(card_title)
|
||||||
|
context = await get_chunk_with_context(
|
||||||
|
nc_client=nc_client,
|
||||||
|
user_id=nc_client.username,
|
||||||
|
doc_id=card.id,
|
||||||
|
doc_type="deck_card",
|
||||||
|
chunk_start=0,
|
||||||
|
chunk_end=len(card_title),
|
||||||
|
context_chars=100,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify context was fetched successfully
|
||||||
|
assert context is not None, "Chunk context should not be None"
|
||||||
|
assert card_title in context.chunk_text, (
|
||||||
|
f"Card title '{card_title}' should be in chunk_text"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify context includes description
|
||||||
|
assert card_description[:50] in context.after_context, (
|
||||||
|
"Card description should be in after_context"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"✓ Successfully fetched chunk context for Deck card {card.id}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup
|
||||||
|
try:
|
||||||
|
await nc_client.deck.delete_board(board.id)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Warning: Failed to cleanup test board: {e}")
|
||||||
@@ -1,26 +1,33 @@
|
|||||||
"""Integration tests for RAG pipeline with OpenAI/GitHub Models API.
|
"""Integration tests for RAG pipeline with multiple LLM providers.
|
||||||
|
|
||||||
These tests validate the complete semantic search and MCP sampling flow using:
|
These tests validate the complete semantic search and MCP sampling flow using:
|
||||||
1. OpenAI embeddings for semantic search
|
1. MCP server's built-in semantic search (embeddings handled server-side)
|
||||||
2. MCP sampling for answer generation
|
2. MCP sampling for answer generation (any generation-capable provider)
|
||||||
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
3. Pre-indexed Nextcloud User Manual as the knowledge base
|
||||||
|
|
||||||
Environment Variables:
|
Usage:
|
||||||
OPENAI_API_KEY: OpenAI API key or GitHub token for models.github.ai
|
# Run with OpenAI (including GitHub Models API)
|
||||||
OPENAI_BASE_URL: Base URL override (e.g., "https://models.github.ai/inference")
|
OPENAI_API_KEY=... pytest tests/integration/test_rag.py --provider=openai -v
|
||||||
OPENAI_EMBEDDING_MODEL: Embedding model (default: "text-embedding-3-small")
|
|
||||||
OPENAI_GENERATION_MODEL: Generation model for sampling (default: "gpt-4o-mini")
|
|
||||||
RAG_MANUAL_PATH: Path to manual PDF in Nextcloud (default: "Nextcloud_User_Manual.pdf")
|
|
||||||
|
|
||||||
For GitHub CI, set:
|
# Run with Ollama
|
||||||
OPENAI_API_KEY: ${{ secrets.GITHUB_TOKEN }}
|
OLLAMA_BASE_URL=http://localhost:11434 OLLAMA_GENERATION_MODEL=llama3.2:1b \\
|
||||||
OPENAI_BASE_URL: https://models.github.ai/inference
|
pytest tests/integration/test_rag.py --provider=ollama -v
|
||||||
OPENAI_EMBEDDING_MODEL: openai/text-embedding-3-small
|
|
||||||
OPENAI_GENERATION_MODEL: openai/gpt-4o-mini
|
# 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:
|
Prerequisites:
|
||||||
- Nextcloud User Manual PDF uploaded to Nextcloud
|
- Nextcloud User Manual PDF uploaded to Nextcloud
|
||||||
- VECTOR_SYNC_ENABLED=true on the MCP server
|
- VECTOR_SYNC_ENABLED=true on the MCP server
|
||||||
|
- Provider-specific environment variables set
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
@@ -33,9 +40,10 @@ import anyio
|
|||||||
import pytest
|
import pytest
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
|
|
||||||
from nextcloud_mcp_server.providers.openai import OpenAIProvider
|
from nextcloud_mcp_server.providers.base import Provider
|
||||||
from tests.conftest import create_mcp_client_session
|
from tests.conftest import create_mcp_client_session
|
||||||
from tests.integration.sampling_support import create_openai_sampling_callback
|
from tests.integration.provider_fixtures import create_generation_provider
|
||||||
|
from tests.integration.sampling_support import create_sampling_callback
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -44,14 +52,14 @@ DEFAULT_MANUAL_PATH = "Nextcloud Manual.pdf"
|
|||||||
|
|
||||||
|
|
||||||
async def llm_judge(
|
async def llm_judge(
|
||||||
provider: "OpenAIProvider",
|
provider: Provider,
|
||||||
ground_truth: str,
|
ground_truth: str,
|
||||||
system_output: str,
|
system_output: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Use LLM to judge if system output aligns with ground truth.
|
"""Use LLM to judge if system output aligns with ground truth.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
provider: OpenAI provider with generation capability
|
provider: Any provider with generation capability
|
||||||
ground_truth: The expected/reference answer
|
ground_truth: The expected/reference answer
|
||||||
system_output: The system's actual output to evaluate
|
system_output: The system's actual output to evaluate
|
||||||
|
|
||||||
@@ -66,17 +74,18 @@ Does the system output contain the key facts from the ground truth?
|
|||||||
|
|
||||||
Answer: TRUE or FALSE"""
|
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)
|
response = await provider.generate(prompt, max_tokens=10)
|
||||||
|
logger.info("LLM Judge response: %s", response)
|
||||||
return "TRUE" in response.upper()
|
return "TRUE" in response.upper()
|
||||||
|
|
||||||
|
|
||||||
# Skip all tests if OpenAI API key not configured
|
# Mark all tests as integration tests
|
||||||
pytestmark = [
|
pytestmark = [
|
||||||
pytest.mark.integration,
|
pytest.mark.integration,
|
||||||
pytest.mark.skipif(
|
pytest.mark.rag,
|
||||||
not os.getenv("OPENAI_API_KEY"),
|
|
||||||
reason="OPENAI_API_KEY not set - skipping OpenAI RAG tests",
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Ground truth fixture path
|
# Ground truth fixture path
|
||||||
@@ -175,78 +184,49 @@ async def indexed_manual_pdf(nc_client, nc_mcp_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def openai_provider():
|
def provider_name(request) -> str:
|
||||||
"""OpenAI provider configured from environment (embeddings only)."""
|
"""Get the provider name from --provider flag.
|
||||||
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(
|
Raises pytest.skip if --provider not specified.
|
||||||
api_key=api_key,
|
"""
|
||||||
base_url=base_url,
|
name = request.config.getoption("--provider")
|
||||||
embedding_model=embedding_model,
|
if not name:
|
||||||
generation_model=None, # Embeddings only
|
pytest.skip("--provider flag required (openai, ollama, anthropic, bedrock)")
|
||||||
)
|
return name
|
||||||
|
|
||||||
yield provider
|
|
||||||
await provider.close()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def openai_generation_provider():
|
async def generation_provider(provider_name: str) -> AsyncGenerator[Provider, None]:
|
||||||
"""OpenAI provider configured for text generation (for sampling callback)."""
|
"""Provider configured for text generation.
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
Requires --provider flag to be set.
|
||||||
|
"""
|
||||||
|
provider = await create_generation_provider(provider_name)
|
||||||
yield provider
|
yield provider
|
||||||
await provider.close()
|
await provider.close()
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
async def nc_mcp_client_with_sampling(
|
async def nc_mcp_client_with_sampling(
|
||||||
anyio_backend, openai_generation_provider
|
anyio_backend, generation_provider, provider_name
|
||||||
) -> AsyncGenerator[ClientSession, Any]:
|
) -> AsyncGenerator[ClientSession, Any]:
|
||||||
"""MCP client with OpenAI-based sampling support.
|
"""MCP client with sampling support using the specified provider.
|
||||||
|
|
||||||
This fixture creates an MCP client that can handle sampling requests
|
This fixture creates an MCP client that can handle sampling requests
|
||||||
from the server using OpenAI for text generation.
|
from the server using the configured generation provider.
|
||||||
"""
|
"""
|
||||||
sampling_callback = create_openai_sampling_callback(openai_generation_provider)
|
sampling_callback = create_sampling_callback(generation_provider)
|
||||||
|
|
||||||
async for session in create_mcp_client_session(
|
async for session in create_mcp_client_session(
|
||||||
url="http://localhost:8000/mcp",
|
url="http://localhost:8000/mcp",
|
||||||
client_name="OpenAI Sampling MCP",
|
client_name=f"Sampling MCP ({provider_name})",
|
||||||
sampling_callback=sampling_callback,
|
sampling_callback=sampling_callback,
|
||||||
):
|
):
|
||||||
yield session
|
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(
|
async def test_semantic_search_retrieval(
|
||||||
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, openai_generation_provider
|
nc_mcp_client, ground_truth_qa, indexed_manual_pdf, generation_provider
|
||||||
):
|
):
|
||||||
"""Test that semantic search retrieves relevant documents from the manual.
|
"""Test that semantic search retrieves relevant documents from the manual.
|
||||||
|
|
||||||
@@ -278,7 +258,7 @@ async def test_semantic_search_retrieval(
|
|||||||
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
# Use LLM judge to evaluate if excerpts are relevant to ground truth
|
||||||
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
all_excerpts = " ".join([r["excerpt"] for r in data["results"]])
|
||||||
is_relevant = await llm_judge(
|
is_relevant = await llm_judge(
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
test_case["ground_truth"],
|
test_case["ground_truth"],
|
||||||
all_excerpts,
|
all_excerpts,
|
||||||
)
|
)
|
||||||
@@ -289,16 +269,16 @@ async def test_semantic_search_answer_with_sampling(
|
|||||||
nc_mcp_client_with_sampling,
|
nc_mcp_client_with_sampling,
|
||||||
ground_truth_qa,
|
ground_truth_qa,
|
||||||
indexed_manual_pdf,
|
indexed_manual_pdf,
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
):
|
):
|
||||||
"""Test semantic search with MCP sampling for answer generation.
|
"""Test semantic search with MCP sampling for answer generation.
|
||||||
|
|
||||||
This tests the full RAG pipeline:
|
This tests the full RAG pipeline:
|
||||||
1. Semantic search retrieves relevant documents
|
1. Semantic search retrieves relevant documents
|
||||||
2. MCP sampling generates an answer from the retrieved context
|
2. MCP sampling generates an answer from the retrieved context
|
||||||
3. OpenAI generates the answer via the sampling callback
|
3. Provider generates the answer via the sampling callback
|
||||||
|
|
||||||
Uses nc_mcp_client_with_sampling which has OpenAI-based sampling enabled.
|
Uses nc_mcp_client_with_sampling which has sampling enabled.
|
||||||
"""
|
"""
|
||||||
# Use the 2FA question - has clear expected answer
|
# Use the 2FA question - has clear expected answer
|
||||||
test_case = ground_truth_qa[0]
|
test_case = ground_truth_qa[0]
|
||||||
@@ -348,7 +328,7 @@ async def test_semantic_search_answer_with_sampling(
|
|||||||
|
|
||||||
# Use LLM judge to evaluate answer relevance
|
# Use LLM judge to evaluate answer relevance
|
||||||
is_relevant = await llm_judge(
|
is_relevant = await llm_judge(
|
||||||
openai_generation_provider,
|
generation_provider,
|
||||||
test_case["ground_truth"],
|
test_case["ground_truth"],
|
||||||
data["generated_answer"],
|
data["generated_answer"],
|
||||||
)
|
)
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
"""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"
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
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}"
|
||||||
|
)
|
||||||
@@ -189,25 +189,14 @@ async def test_get_file_info_returns_none_for_missing_file(mocker):
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
async def test_create_tag_creates_system_tag(mocker):
|
async def test_create_tag_creates_system_tag(mocker):
|
||||||
"""Test that create_tag creates a system tag via OCS API."""
|
"""Test that create_tag creates a system tag via WebDAV."""
|
||||||
mock_http_client = AsyncMock()
|
mock_http_client = AsyncMock()
|
||||||
client = WebDAVClient(mock_http_client, "testuser")
|
client = WebDAVClient(mock_http_client, "testuser")
|
||||||
|
|
||||||
# Mock OCS response
|
# Mock WebDAV response with Content-Location header
|
||||||
mock_response = AsyncMock()
|
mock_response = AsyncMock()
|
||||||
mock_response.status_code = 200
|
mock_response.status_code = 201
|
||||||
mock_response.json = mocker.Mock(
|
mock_response.headers = {"Content-Location": "/remote.php/dav/systemtags/42"}
|
||||||
return_value={
|
|
||||||
"ocs": {
|
|
||||||
"data": {
|
|
||||||
"id": 42,
|
|
||||||
"name": "vector-index",
|
|
||||||
"userVisible": True,
|
|
||||||
"userAssignable": True,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
mock_response.raise_for_status = mocker.Mock()
|
mock_response.raise_for_status = mocker.Mock()
|
||||||
|
|
||||||
mock_http_client.post = AsyncMock(return_value=mock_response)
|
mock_http_client.post = AsyncMock(return_value=mock_response)
|
||||||
@@ -224,8 +213,10 @@ async def test_create_tag_creates_system_tag(mocker):
|
|||||||
# Verify API call
|
# Verify API call
|
||||||
mock_http_client.post.assert_called_once()
|
mock_http_client.post.assert_called_once()
|
||||||
call_args = mock_http_client.post.call_args
|
call_args = mock_http_client.post.call_args
|
||||||
assert call_args[0][0] == "/ocs/v2.php/apps/systemtags/api/v1/tags"
|
assert call_args[0][0] == "/remote.php/dav/systemtags/"
|
||||||
assert call_args[1]["json"]["name"] == "vector-index"
|
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
|
@pytest.mark.unit
|
||||||
|
|||||||
Reference in New Issue
Block a user