Compare commits
92 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe53e93fe9 | |||
| 71d4c44b05 | |||
| 1d9168f614 | |||
| 9229440a58 | |||
| e507f29e83 | |||
| 5ac6d8d396 | |||
| ab71003c5d | |||
| 726b71eea1 | |||
| 3e50924169 | |||
| b2773317ef | |||
| dce3ca9a70 | |||
| 18e5baf2a5 | |||
| 24bc29ea64 | |||
| 44e7e2e09b | |||
| bcc0bfee8d | |||
| 0f31d16158 | |||
| 7c0b84d398 | |||
| f51b27ba19 | |||
| 010eb40d5c | |||
| 960d060d27 | |||
| 76e6c12b56 | |||
| 76e305006c | |||
| 8887aa241a | |||
| 10d44edf4c | |||
| f5b4658d5a | |||
| 39d160ce48 | |||
| a11ae9c027 | |||
| 81efa6e263 | |||
| aaddd0d5a9 | |||
| a5eb16c1ac | |||
| 6f7a06e558 | |||
| 0e4c8453bf | |||
| 2dba3179bd | |||
| 5f0e208193 | |||
| 3779ec3e17 | |||
| f2df19c39b | |||
| 5562c943c0 | |||
| 12c02ffe00 | |||
| d2e1391f37 | |||
| ac91aacaf5 | |||
| ad9fcddca1 | |||
| 0e57cf6389 | |||
| b9a185ba1c | |||
| 9aa6b44397 | |||
| 1aa21663b7 | |||
| d145e4d5de | |||
| cf4ed4a641 | |||
| 8d84d95ada | |||
| 992d380585 | |||
| e51fc48206 | |||
| 2657071404 | |||
| 75325f16fc | |||
| 1d4ff3fbe0 | |||
| 778b08cc84 | |||
| 8cab588f21 | |||
| 8233cc9dcf | |||
| 0d259d2dfd | |||
| dfc676a847 | |||
| cf627a9c48 | |||
| 037e88e416 | |||
| dae2f276ae | |||
| d94610d0ec | |||
| af0b9c1f93 | |||
| 2d7360ebd7 | |||
| 56542802bc | |||
| c03dbd1b55 | |||
| 99925d9f22 | |||
| 0dfaf954d7 | |||
| b3fe7099cb | |||
| 7152537fd4 | |||
| 9d31925f27 | |||
| 3a322c34bc | |||
| b1bd025aac | |||
| 8a1c604d78 | |||
| 3616dee54c | |||
| dbb36a7b63 | |||
| f1797b2f8e | |||
| 1d5d4f86d7 | |||
| 44030805f1 | |||
| afd7e69f76 | |||
| 31be72ae24 | |||
| 6bd05a81bf | |||
| a4e3f0b354 | |||
| 0f23964752 | |||
| 91d06acfb4 | |||
| 90874ca7cd | |||
| da8fed3382 | |||
| 8963e65f1b | |||
| 75c3868e74 | |||
| bce6686494 | |||
| 291a13c064 | |||
| 119a422a35 |
@@ -15,13 +15,13 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||||
with:
|
with:
|
||||||
python-version: '3.11'
|
python-version: '3.11'
|
||||||
|
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code Review
|
- name: Run Claude Code Review
|
||||||
id: claude-review
|
id: claude-review
|
||||||
uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
|
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
allowed_bots: "renovate-bot-cbcoutinho"
|
allowed_bots: "renovate-bot-cbcoutinho"
|
||||||
|
|||||||
@@ -26,13 +26,13 @@ jobs:
|
|||||||
actions: read # Required for Claude to read CI results on PRs
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
|
||||||
- name: Run Claude Code
|
- name: Run Claude Code
|
||||||
id: claude
|
id: claude
|
||||||
uses: anthropics/claude-code-action@ea36d6abdedc17fc2a671b36060770b208a6f8f1 # v1.0.51
|
uses: anthropics/claude-code-action@edd85d61533cbba7b57ed0ca4af1750b1fdfd3c4 # v1.0.55
|
||||||
with:
|
with:
|
||||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
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: |
|
||||||
@@ -34,18 +34,18 @@ jobs:
|
|||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
models: read
|
models: read
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Run docker compose with vector sync
|
- name: Run docker compose with vector sync
|
||||||
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
|
||||||
@@ -97,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload test results
|
- name: Upload test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
with:
|
with:
|
||||||
name: rag-evaluation-results
|
name: rag-evaluation-results
|
||||||
path: |
|
path: |
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Install Python 3.11
|
- name: Install Python 3.11
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
|
||||||
- name: Check format
|
- name: Check format
|
||||||
@@ -27,7 +27,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
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@44454db4f0199b8b9685a5d763dc37cbf79108e1 # v2
|
uses: shivammathur/setup-php@44454db4f0199b8b9685a5d763dc37cbf79108e1 # 2.36.0
|
||||||
with:
|
with:
|
||||||
php-version: 8.4
|
php-version: 8.4
|
||||||
coverage: none
|
coverage: none
|
||||||
|
|||||||
@@ -5,6 +5,44 @@ All notable changes to the Nextcloud MCP Server will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
and this project adheres to [PEP 440](https://peps.python.org/pep-0440/).
|
||||||
|
|
||||||
|
## v0.64.3 (2026-02-21)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #574 fourth review round
|
||||||
|
- address PR #574 third review round
|
||||||
|
- address PR #574 second review round
|
||||||
|
- address PR #574 review comments
|
||||||
|
- wrap raw list returns in response models to produce single TextContent block
|
||||||
|
|
||||||
|
## v0.64.2 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## v0.64.1 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## v0.64.0 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add self-signed SSL certificate support for Nextcloud connections
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add type: ignore for caldav ssl_verify_cert parameter
|
||||||
|
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||||
|
|
||||||
## v0.63.5 (2026-02-16)
|
## v0.63.5 (2026-02-16)
|
||||||
|
|
||||||
### Refactor
|
### Refactor
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1e94ffbbbdd7a93f867dd0d8d5720d9e1f89fab
|
||||||
|
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ FROM docker.io/library/python:3.12-slim-trixie@sha256:9e01bf1ae5db7649a236da7be1
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install uv for fast dependency management
|
# Install uv for fast dependency management
|
||||||
COPY --from=ghcr.io/astral-sh/uv:0.10.0@sha256:78a7ff97cd27b7124a5f3c2aefe146170793c56a1e03321dd31a289f6d82a04f /uv /uvx /bin/
|
COPY --from=ghcr.io/astral-sh/uv:0.10.4@sha256:4cac394b6b72846f8a85a7a0e577c6d61d4e17fe2ccee65d9451a8b3c9efb4ac /uv /uvx /bin/
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
# 1. git (required for caldav dependency from git)
|
# 1. git (required for caldav dependency from git)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.commitizen]
|
[tool.commitizen]
|
||||||
name = "cz_conventional_commits"
|
name = "cz_conventional_commits"
|
||||||
version = "0.57.49"
|
version = "0.57.76"
|
||||||
tag_format = "nextcloud-mcp-server-$version"
|
tag_format = "nextcloud-mcp-server-$version"
|
||||||
version_scheme = "semver"
|
version_scheme = "semver"
|
||||||
update_changelog_on_bump = true
|
update_changelog_on_bump = true
|
||||||
|
|||||||
@@ -14,6 +14,90 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Configurable resource limits
|
- Configurable resource limits
|
||||||
- Grafana dashboard annotations
|
- Grafana dashboard annotations
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.76 (2026-02-24)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.75 (2026-02-23)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.74 (2026-02-21)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.73 (2026-02-21)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #574 fourth review round
|
||||||
|
- address PR #574 third review round
|
||||||
|
- address PR #574 second review round
|
||||||
|
- address PR #574 review comments
|
||||||
|
- wrap raw list returns in response models to produce single TextContent block
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.72 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.71 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.70 (2026-02-20)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- address PR #571 review comments
|
||||||
|
- resolve stale credentials causing astrolabe background sync test failures
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- enforce PLC0415 (import-outside-top-level) for source code
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.69 (2026-02-20)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.68 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.67 (2026-02-19)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.66 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.65 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.64 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.63 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.62 (2026-02-18)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **deps**: update dependency mcp to >=1.26,<1.27
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.61 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.60 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.59 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.58 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.57 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.56 (2026-02-18)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.55 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.54 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.53 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.52 (2026-02-17)
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.51 (2026-02-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- add self-signed SSL certificate support for Nextcloud connections
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- add type: ignore for caldav ssl_verify_cert parameter
|
||||||
|
- convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
|
||||||
|
|
||||||
|
## nextcloud-mcp-server-0.57.50 (2026-02-16)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
## nextcloud-mcp-server-0.57.49 (2026-02-16)
|
||||||
|
|
||||||
### Refactor
|
### Refactor
|
||||||
|
|||||||
@@ -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.3
|
version: 1.17.0
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.42.0
|
version: 1.45.0
|
||||||
digest: sha256:a9aef6e290f23b1ed961450e0635eb0bce42f8c52805276901a80df7c27473f6
|
digest: sha256:a325b7093a64921fb5c6648c19c31a61799c8b279da21f08b9e892a9e5a37227
|
||||||
generated: "2026-02-10T11:10:44.457881902Z"
|
generated: "2026-02-23T05:14:08.147145912Z"
|
||||||
|
|||||||
@@ -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.57.49
|
version: 0.57.76
|
||||||
appVersion: "0.63.5"
|
appVersion: "0.64.3"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -27,10 +27,10 @@ annotations:
|
|||||||
grafana_dashboard_folder: "Nextcloud MCP"
|
grafana_dashboard_folder: "Nextcloud MCP"
|
||||||
dependencies:
|
dependencies:
|
||||||
- name: qdrant
|
- name: qdrant
|
||||||
version: "1.16.3"
|
version: "1.17.0"
|
||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.42.0"
|
version: "1.45.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+4
-4
@@ -3,7 +3,7 @@ services:
|
|||||||
# https://hub.docker.com/_/mariadb
|
# https://hub.docker.com/_/mariadb
|
||||||
db:
|
db:
|
||||||
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
# Note: Check the recommend version here: https://docs.nextcloud.com/server/latest/admin_manual/installation/system_requirements.html#server
|
||||||
image: docker.io/library/mariadb:lts@sha256:345fa26d595e8c7fe298e0c4098ed400356f502458769c8902229b3437d6da2b
|
image: docker.io/library/mariadb:lts@sha256:8164f184d16c30e2f159e30518113667b796306dff0fe558876ab1ff521a682f
|
||||||
restart: always
|
restart: always
|
||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
@@ -19,7 +19,7 @@ services:
|
|||||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||||
# https://hub.docker.com/_/redis
|
# https://hub.docker.com/_/redis
|
||||||
redis:
|
redis:
|
||||||
image: docker.io/library/redis:alpine@sha256:0804c395e634e624243387d3c3a9c45fcaca876d313c2c8b52c3fdf9a912dded
|
image: docker.io/library/redis:alpine@sha256:2afba59292f25f5d1af200496db41bea2c6c816b059f57ae74703a50a03a27d0
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
@@ -207,7 +207,7 @@ services:
|
|||||||
- oauth-tokens:/app/data
|
- oauth-tokens:/app/data
|
||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.5.1@sha256:b80a48090594367bd8cf6fe2019466ac4ea49de4d0830fb2a43256eda37b18f5
|
image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
@@ -294,7 +294,7 @@ services:
|
|||||||
- smithery
|
- smithery
|
||||||
|
|
||||||
qdrant:
|
qdrant:
|
||||||
image: docker.io/qdrant/qdrant:v1.16.3@sha256:0425e3e03e7fd9b3dc95c4214546afe19de2eb2e28ca621441a56663ac6e1f46
|
image: docker.io/qdrant/qdrant:v1.17.0@sha256:f1c7272cdac52b38c1a0e89313922d940ba50afd90d593a1605dbbc214e66ffb
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:6333:6333 # REST API
|
- 127.0.0.1:6333:6333 # REST API
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,15 @@ import time
|
|||||||
from importlib.metadata import version
|
from importlib.metadata import version
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from qdrant_client.models import Filter
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -196,16 +202,12 @@ async def get_server_status(request: Request) -> JSONResponse:
|
|||||||
# Public endpoint - no authentication required
|
# Public endpoint - no authentication required
|
||||||
|
|
||||||
# Get configuration
|
# Get configuration
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Calculate uptime
|
# Calculate uptime
|
||||||
uptime_seconds = int(time.time() - _server_start_time)
|
uptime_seconds = int(time.time() - _server_start_time)
|
||||||
|
|
||||||
# Determine auth mode using proper mode detection
|
# Determine auth mode using proper mode detection
|
||||||
from nextcloud_mcp_server.config_validators import AuthMode, detect_auth_mode
|
|
||||||
|
|
||||||
mode = detect_auth_mode(settings)
|
mode = detect_auth_mode(settings)
|
||||||
|
|
||||||
# Map deployment mode to auth_mode for API response
|
# Map deployment mode to auth_mode for API response
|
||||||
@@ -266,8 +268,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
|
|||||||
"""
|
"""
|
||||||
# Public endpoint - no authentication required
|
# Public endpoint - no authentication required
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -299,11 +299,6 @@ async def get_vector_sync_status(request: Request) -> JSONResponse:
|
|||||||
# 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.vector.placeholder import get_placeholder_filter
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection, excluding placeholders
|
# Count documents in collection, excluding placeholders
|
||||||
@@ -375,8 +370,6 @@ async def get_user_session(request: Request) -> JSONResponse:
|
|||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
enable_offline_access = settings.enable_offline_access
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
|
|||||||
@@ -15,18 +15,16 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -158,7 +156,7 @@ def _extract_basic_auth(
|
|||||||
return username, password, None
|
return username, password, None
|
||||||
|
|
||||||
|
|
||||||
async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
async def _get_app_password_storage(request: Request) -> RefreshTokenStorage:
|
||||||
"""Get or initialize RefreshTokenStorage for app password operations.
|
"""Get or initialize RefreshTokenStorage for app password operations.
|
||||||
|
|
||||||
Checks app.state.storage first, then falls back to creating from environment.
|
Checks app.state.storage first, then falls back to creating from environment.
|
||||||
@@ -170,8 +168,6 @@ async def _get_app_password_storage(request: Request) -> "RefreshTokenStorage":
|
|||||||
Returns:
|
Returns:
|
||||||
Initialized RefreshTokenStorage instance
|
Initialized RefreshTokenStorage instance
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = getattr(request.app.state, "storage", None)
|
storage = getattr(request.app.state, "storage", None)
|
||||||
|
|
||||||
if not storage:
|
if not storage:
|
||||||
@@ -202,8 +198,6 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
|||||||
- Only the user who owns the password can provision it
|
- Only the user who owns the password can provision it
|
||||||
- Rate limited to prevent brute-force attacks
|
- Rate limited to prevent brute-force attacks
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
# Get user_id from path
|
# Get user_id from path
|
||||||
path_user_id = request.path_params.get("user_id")
|
path_user_id = request.path_params.get("user_id")
|
||||||
if not path_user_id:
|
if not path_user_id:
|
||||||
@@ -364,8 +358,6 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires BasicAuth with the user's credentials.
|
Requires BasicAuth with the user's credentials.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
# Get user_id from path
|
# Get user_id from path
|
||||||
path_user_id = request.path_params.get("user_id")
|
path_user_id = request.path_params.get("user_id")
|
||||||
if not path_user_id:
|
if not path_user_id:
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import Any
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
if TYPE_CHECKING:
|
|
||||||
pass
|
|
||||||
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -29,6 +26,17 @@ from nextcloud_mcp_server.api.management import (
|
|||||||
extract_bearer_token,
|
extract_bearer_token,
|
||||||
validate_token_and_get_user,
|
validate_token_and_get_user,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||||
|
from nextcloud_mcp_server.search import (
|
||||||
|
BM25HybridSearchAlgorithm,
|
||||||
|
SemanticSearchAlgorithm,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
from nextcloud_mcp_server.vector.visualization import compute_pca_coordinates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -68,8 +76,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires OAuth bearer token for user filtering.
|
Requires OAuth bearer token for user filtering.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -144,12 +150,6 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
if fusion not in valid_fusions:
|
if fusion not in valid_fusions:
|
||||||
fusion = "rrf"
|
fusion = "rrf"
|
||||||
|
|
||||||
# Execute search using the appropriate algorithm
|
|
||||||
from nextcloud_mcp_server.search import (
|
|
||||||
BM25HybridSearchAlgorithm,
|
|
||||||
SemanticSearchAlgorithm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select search algorithm
|
# Select search algorithm
|
||||||
if algorithm == "semantic":
|
if algorithm == "semantic":
|
||||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||||
@@ -254,17 +254,9 @@ async def unified_search(request: Request) -> JSONResponse:
|
|||||||
# Optional PCA coordinates
|
# Optional PCA coordinates
|
||||||
if include_pca and len(paginated_results) >= 2:
|
if include_pca and len(paginated_results) >= 2:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.vector.visualization import (
|
|
||||||
compute_pca_coordinates,
|
|
||||||
)
|
|
||||||
|
|
||||||
if search_algo.query_embedding is not None:
|
if search_algo.query_embedding is not None:
|
||||||
query_embedding = search_algo.query_embedding
|
query_embedding = search_algo.query_embedding
|
||||||
else:
|
else:
|
||||||
from nextcloud_mcp_server.embedding.service import (
|
|
||||||
get_embedding_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
@@ -305,8 +297,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
Requires OAuth bearer token for user filtering.
|
Requires OAuth bearer token for user filtering.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
@@ -354,12 +344,6 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
if fusion not in valid_fusions:
|
if fusion not in valid_fusions:
|
||||||
fusion = "rrf"
|
fusion = "rrf"
|
||||||
|
|
||||||
# Execute search using the appropriate algorithm
|
|
||||||
from nextcloud_mcp_server.search import (
|
|
||||||
BM25HybridSearchAlgorithm,
|
|
||||||
SemanticSearchAlgorithm,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Select search algorithm
|
# Select search algorithm
|
||||||
if algorithm == "semantic":
|
if algorithm == "semantic":
|
||||||
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
search_algo = SemanticSearchAlgorithm(score_threshold=score_threshold)
|
||||||
@@ -428,18 +412,10 @@ async def vector_search(request: Request) -> JSONResponse:
|
|||||||
# Compute PCA coordinates for visualization using shared function
|
# Compute PCA coordinates for visualization using shared function
|
||||||
if include_pca and len(all_results) >= 2:
|
if include_pca and len(all_results) >= 2:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.vector.visualization import (
|
|
||||||
compute_pca_coordinates,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get query embedding from search algorithm or generate it
|
# Get query embedding from search algorithm or generate it
|
||||||
if search_algo.query_embedding is not None:
|
if search_algo.query_embedding is not None:
|
||||||
query_embedding = search_algo.query_embedding
|
query_embedding = search_algo.query_embedding
|
||||||
else:
|
else:
|
||||||
from nextcloud_mcp_server.embedding.service import (
|
|
||||||
get_embedding_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
|
|
||||||
@@ -549,9 +525,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Initialize authenticated Nextcloud client
|
# Initialize authenticated Nextcloud client
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
|
||||||
|
|
||||||
async with NextcloudClient.from_token(
|
async with NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host, token=token, username=user_id
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
) as nc_client:
|
) as nc_client:
|
||||||
@@ -581,14 +554,6 @@ async def get_chunk_context(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
if doc_type == "file":
|
if doc_type == "file":
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
from nextcloud_mcp_server.vector.placeholder import (
|
|
||||||
get_placeholder_filter,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
@@ -735,8 +700,6 @@ async def get_pdf_preview(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Download PDF via WebDAV using user's token
|
# Download PDF via WebDAV using user's token
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
|
||||||
|
|
||||||
async with NextcloudClient.from_token(
|
async with NextcloudClient.from_token(
|
||||||
base_url=nextcloud_host, token=token, username=user_id
|
base_url=nextcloud_host, token=token, username=user_id
|
||||||
) as nc_client:
|
) as nc_client:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from nextcloud_mcp_server.api.management import (
|
|||||||
extract_bearer_token,
|
extract_bearer_token,
|
||||||
validate_token_and_get_user,
|
validate_token_and_get_user,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
@@ -115,8 +116,6 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Get Bearer token from request
|
# Get Bearer token from request
|
||||||
token = extract_bearer_token(request)
|
token = extract_bearer_token(request)
|
||||||
if not token:
|
if not token:
|
||||||
@@ -180,8 +179,6 @@ async def create_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Parse request body
|
# Parse request body
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
event = body.get("event")
|
event = body.get("event")
|
||||||
@@ -256,8 +253,6 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.client.webhooks import WebhooksClient
|
|
||||||
|
|
||||||
# Get webhook_id from path parameter
|
# Get webhook_id from path parameter
|
||||||
webhook_id = request.path_params.get("webhook_id")
|
webhook_id = request.path_params.get("webhook_id")
|
||||||
if not webhook_id:
|
if not webhook_id:
|
||||||
|
|||||||
+73
-91
@@ -10,22 +10,17 @@ from collections.abc import AsyncIterator
|
|||||||
from contextlib import AsyncExitStack, asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import TYPE_CHECKING, Optional, cast
|
from typing import Optional, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
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 mcp.server.transport_security import TransportSecuritySettings
|
||||||
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
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
|
||||||
@@ -36,6 +31,23 @@ from starlette.staticfiles import StaticFiles
|
|||||||
from starlette.types import ASGIApp, Receive, Send
|
from starlette.types import ASGIApp, Receive, Send
|
||||||
from starlette.types import Scope as StarletteScope
|
from starlette.types import Scope as StarletteScope
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.api import (
|
||||||
|
create_webhook,
|
||||||
|
delete_app_password,
|
||||||
|
delete_webhook,
|
||||||
|
get_app_password_status,
|
||||||
|
get_chunk_context,
|
||||||
|
get_installed_apps,
|
||||||
|
get_pdf_preview,
|
||||||
|
get_server_status,
|
||||||
|
get_user_session,
|
||||||
|
get_vector_sync_status,
|
||||||
|
list_webhooks,
|
||||||
|
provision_app_password,
|
||||||
|
revoke_user_access,
|
||||||
|
unified_search,
|
||||||
|
vector_search,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.auth import (
|
from nextcloud_mcp_server.auth import (
|
||||||
InsufficientScopeError,
|
InsufficientScopeError,
|
||||||
discover_all_scopes,
|
discover_all_scopes,
|
||||||
@@ -43,7 +55,38 @@ from nextcloud_mcp_server.auth import (
|
|||||||
has_required_scopes,
|
has_required_scopes,
|
||||||
is_jwt_token,
|
is_jwt_token,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||||
|
oauth_login,
|
||||||
|
oauth_login_callback,
|
||||||
|
oauth_logout,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
||||||
|
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||||
|
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||||
|
oauth_authorize,
|
||||||
|
oauth_authorize_nextcloud,
|
||||||
|
oauth_callback,
|
||||||
|
oauth_callback_nextcloud,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
|
||||||
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
|
revoke_session,
|
||||||
|
user_info_html,
|
||||||
|
vector_sync_status_fragment,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.viz_routes import (
|
||||||
|
chunk_context_endpoint,
|
||||||
|
vector_visualization_html,
|
||||||
|
vector_visualization_search,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.auth.webhook_routes import (
|
||||||
|
disable_webhook_preset,
|
||||||
|
enable_webhook_preset,
|
||||||
|
webhook_management_pane,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
@@ -82,6 +125,11 @@ from nextcloud_mcp_server.server import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
|
||||||
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
from nextcloud_mcp_server.vector import processor_task, scanner_task
|
||||||
|
from nextcloud_mcp_server.vector.oauth_sync import (
|
||||||
|
oauth_processor_task,
|
||||||
|
user_manager_task,
|
||||||
|
)
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
HTTPXClientInstrumentor().instrument()
|
HTTPXClientInstrumentor().instrument()
|
||||||
@@ -106,7 +154,7 @@ def initialize_document_processors():
|
|||||||
if "unstructured" in config["processors"]:
|
if "unstructured" in config["processors"]:
|
||||||
unst_config = config["processors"]["unstructured"]
|
unst_config = config["processors"]["unstructured"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.unstructured import (
|
from nextcloud_mcp_server.document_processors.unstructured import ( # noqa: PLC0415
|
||||||
UnstructuredProcessor,
|
UnstructuredProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,7 +175,7 @@ def initialize_document_processors():
|
|||||||
if "tesseract" in config["processors"]:
|
if "tesseract" in config["processors"]:
|
||||||
tess_config = config["processors"]["tesseract"]
|
tess_config = config["processors"]["tesseract"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.tesseract import (
|
from nextcloud_mcp_server.document_processors.tesseract import ( # noqa: PLC0415
|
||||||
TesseractProcessor,
|
TesseractProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -145,7 +193,7 @@ def initialize_document_processors():
|
|||||||
if "pymupdf" in config["processors"]:
|
if "pymupdf" in config["processors"]:
|
||||||
pymupdf_config = config["processors"]["pymupdf"]
|
pymupdf_config = config["processors"]["pymupdf"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.pymupdf import (
|
from nextcloud_mcp_server.document_processors.pymupdf import ( # noqa: PLC0415
|
||||||
PyMuPDFProcessor,
|
PyMuPDFProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -165,7 +213,7 @@ def initialize_document_processors():
|
|||||||
if "custom" in config["processors"]:
|
if "custom" in config["processors"]:
|
||||||
custom_config = config["processors"]["custom"]
|
custom_config = config["processors"]["custom"]
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.document_processors.custom_http import (
|
from nextcloud_mcp_server.document_processors.custom_http import ( # noqa: PLC0415
|
||||||
CustomHTTPProcessor,
|
CustomHTTPProcessor,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -431,8 +479,6 @@ class SmitheryConfigMiddleware:
|
|||||||
) -> None:
|
) -> None:
|
||||||
if scope["type"] == "http":
|
if scope["type"] == "http":
|
||||||
# Extract config from query parameters
|
# Extract config from query parameters
|
||||||
from urllib.parse import parse_qs
|
|
||||||
|
|
||||||
query_string = scope.get("query_string", b"").decode("utf-8")
|
query_string = scope.get("query_string", b"").decode("utf-8")
|
||||||
params = parse_qs(query_string)
|
params = parse_qs(query_string)
|
||||||
|
|
||||||
@@ -507,8 +553,6 @@ async def load_oauth_client_credentials(
|
|||||||
|
|
||||||
# Try loading from SQLite storage
|
# Try loading from SQLite storage
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
|
||||||
@@ -559,9 +603,6 @@ async def load_oauth_client_credentials(
|
|||||||
logger.info(f"Requesting token type: {token_type}")
|
logger.info(f"Requesting token type: {token_type}")
|
||||||
|
|
||||||
# Ensure OAuth client in SQLite storage
|
# Ensure OAuth client in SQLite storage
|
||||||
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
|
||||||
@@ -625,8 +666,6 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Initialize persistent storage (for webhook tracking and future features)
|
# Initialize persistent storage (for webhook tracking and future features)
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
logger.info("Persistent storage initialized (webhook tracking enabled)")
|
||||||
@@ -756,10 +795,6 @@ async def setup_oauth_config():
|
|||||||
refresh_token_storage = None
|
refresh_token_storage = None
|
||||||
if enable_offline_access:
|
if enable_offline_access:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import (
|
|
||||||
RefreshTokenStorage,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate encryption key before initializing
|
# Validate encryption key before initializing
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
if not encryption_key:
|
if not encryption_key:
|
||||||
@@ -881,8 +916,6 @@ async def setup_oauth_config():
|
|||||||
oauth_client = None
|
oauth_client = None
|
||||||
if enable_offline_access and refresh_token_storage and is_external_idp:
|
if enable_offline_access and refresh_token_storage and is_external_idp:
|
||||||
# For external IdP mode, create generic OIDC client for token operations
|
# For external IdP mode, create generic OIDC client for token operations
|
||||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
|
||||||
|
|
||||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||||
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
|
# Note: This redirect_uri is for OAuth client initialization, not used for actual redirects
|
||||||
# since this client is used for backend token operations (exchange, refresh)
|
# since this client is used for backend token operations (exchange, refresh)
|
||||||
@@ -1077,8 +1110,6 @@ async def setup_oauth_config_for_multi_user_basic(
|
|||||||
refresh_token_storage = None
|
refresh_token_storage = None
|
||||||
if settings.enable_offline_access:
|
if settings.enable_offline_access:
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
if not encryption_key:
|
if not encryption_key:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -1544,8 +1575,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# BasicAuth mode - initialize storage for webhook management
|
# BasicAuth mode - initialize storage for webhook management
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
basic_auth_storage = RefreshTokenStorage.from_env()
|
basic_auth_storage = RefreshTokenStorage.from_env()
|
||||||
await basic_auth_storage.initialize()
|
await basic_auth_storage.initialize()
|
||||||
logger.info("Initialized refresh token storage for webhook management")
|
logger.info("Initialized refresh token storage for webhook management")
|
||||||
@@ -1653,7 +1682,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
# Initialize Qdrant collection before starting background tasks
|
# Initialize Qdrant collection before starting background tasks
|
||||||
logger.info("Initializing Qdrant collection...")
|
logger.info("Initializing Qdrant collection...")
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await get_qdrant_client() # Triggers collection creation if needed
|
await get_qdrant_client() # Triggers collection creation if needed
|
||||||
@@ -1745,12 +1773,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
mode_desc = "OAuth mode" if oauth_enabled else "Multi-user BasicAuth mode"
|
||||||
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
logger.info(f"Starting background vector sync tasks for {mode_desc}")
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
|
||||||
from nextcloud_mcp_server.vector.oauth_sync import (
|
|
||||||
oauth_processor_task,
|
|
||||||
user_manager_task,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get nextcloud_host (from settings - already validated)
|
# Get nextcloud_host (from settings - already validated)
|
||||||
nextcloud_host_for_sync = settings.nextcloud_host
|
nextcloud_host_for_sync = settings.nextcloud_host
|
||||||
if not nextcloud_host_for_sync:
|
if not nextcloud_host_for_sync:
|
||||||
@@ -1815,7 +1837,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
|
|
||||||
# Initialize Qdrant collection before starting background tasks
|
# Initialize Qdrant collection before starting background tasks
|
||||||
logger.info("Initializing Qdrant collection...")
|
logger.info("Initializing Qdrant collection...")
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await get_qdrant_client() # Triggers collection creation if needed
|
await get_qdrant_client() # Triggers collection creation if needed
|
||||||
@@ -1826,6 +1847,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
f"Cannot start vector sync - Qdrant initialization failed: {e}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
# Clean up stale app passwords at startup (BasicAuth mode only)
|
||||||
|
if not oauth_enabled:
|
||||||
|
try:
|
||||||
|
removed = await token_storage.cleanup_invalid_app_passwords(
|
||||||
|
nextcloud_host=nextcloud_host_for_sync
|
||||||
|
)
|
||||||
|
if removed:
|
||||||
|
logger.info(
|
||||||
|
f"Cleaned up {len(removed)} stale app password(s): {removed}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"App password cleanup failed (non-fatal): {e}")
|
||||||
|
|
||||||
# Initialize shared state
|
# Initialize shared state
|
||||||
send_stream, receive_stream = anyio.create_memory_object_stream(
|
send_stream, receive_stream = anyio.create_memory_object_stream(
|
||||||
max_buffer_size=settings.vector_sync_queue_max_size
|
max_buffer_size=settings.vector_sync_queue_max_size
|
||||||
@@ -2113,24 +2147,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
settings.enable_multi_user_basic_auth and settings.enable_offline_access
|
||||||
)
|
)
|
||||||
if enable_management_apis:
|
if enable_management_apis:
|
||||||
from nextcloud_mcp_server.api import (
|
|
||||||
create_webhook,
|
|
||||||
delete_app_password,
|
|
||||||
delete_webhook,
|
|
||||||
get_app_password_status,
|
|
||||||
get_chunk_context,
|
|
||||||
get_installed_apps,
|
|
||||||
get_pdf_preview,
|
|
||||||
get_server_status,
|
|
||||||
get_user_session,
|
|
||||||
get_vector_sync_status,
|
|
||||||
list_webhooks,
|
|
||||||
provision_app_password,
|
|
||||||
revoke_user_access,
|
|
||||||
unified_search,
|
|
||||||
vector_search,
|
|
||||||
)
|
|
||||||
|
|
||||||
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
routes.append(Route("/api/v1/status", get_server_status, methods=["GET"]))
|
||||||
routes.append(
|
routes.append(
|
||||||
Route(
|
Route(
|
||||||
@@ -2238,8 +2254,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
f"OAuth provisioning routes enabled for mode: {mode.value} "
|
f"OAuth provisioning routes enabled for mode: {mode.value} "
|
||||||
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
|
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
|
||||||
)
|
)
|
||||||
# Import OAuth routes (ADR-004 Progressive Consent)
|
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
|
||||||
|
|
||||||
def oauth_protected_resource_metadata(request):
|
def oauth_protected_resource_metadata(request):
|
||||||
"""RFC 9728 Protected Resource Metadata endpoint.
|
"""RFC 9728 Protected Resource Metadata endpoint.
|
||||||
@@ -2301,12 +2315,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Add unified OAuth callback endpoint supporting both flows
|
# Add unified OAuth callback endpoint supporting both flows
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
|
||||||
oauth_authorize_nextcloud,
|
|
||||||
oauth_callback,
|
|
||||||
oauth_callback_nextcloud,
|
|
||||||
)
|
|
||||||
|
|
||||||
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
|
routes.append(Route("/oauth/callback", oauth_callback, methods=["GET"]))
|
||||||
logger.info(
|
logger.info(
|
||||||
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
|
"OAuth unified callback enabled: /oauth/callback?flow={browser|provisioning}"
|
||||||
@@ -2335,8 +2343,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
|
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
|
||||||
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
|
||||||
if oauth_enabled:
|
if oauth_enabled:
|
||||||
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
|
|
||||||
|
|
||||||
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
|
||||||
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
|
||||||
|
|
||||||
@@ -2344,12 +2350,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
# Available in OAuth modes AND multi-user BasicAuth with offline access
|
||||||
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
|
# (hybrid mode). Separate from MCP tool auth - Management API uses OAuth
|
||||||
if oauth_provisioning_available:
|
if oauth_provisioning_available:
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|
||||||
oauth_login,
|
|
||||||
oauth_login_callback,
|
|
||||||
oauth_logout,
|
|
||||||
)
|
|
||||||
|
|
||||||
routes.append(
|
routes.append(
|
||||||
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
Route("/oauth/login", oauth_login, methods=["GET"], name="oauth_login")
|
||||||
)
|
)
|
||||||
@@ -2372,24 +2372,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Add user info routes (available in both BasicAuth and OAuth modes)
|
# Add user info routes (available in both BasicAuth and OAuth modes)
|
||||||
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
# ADR-016: Skip /app admin UI in Smithery stateless mode (no vector sync, webhooks)
|
||||||
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
if deployment_mode != DeploymentMode.SMITHERY_STATELESS:
|
||||||
# These require session authentication, so we wrap them in a separate app
|
|
||||||
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
|
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
|
||||||
revoke_session,
|
|
||||||
user_info_html,
|
|
||||||
vector_sync_status_fragment,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.auth.viz_routes import (
|
|
||||||
chunk_context_endpoint,
|
|
||||||
vector_visualization_html,
|
|
||||||
vector_visualization_search,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.auth.webhook_routes import (
|
|
||||||
disable_webhook_preset,
|
|
||||||
enable_webhook_preset,
|
|
||||||
webhook_management_pane,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create a separate Starlette app for browser routes that need session auth
|
# Create a separate Starlette app for browser routes that need session auth
|
||||||
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
# This prevents SessionAuthBackend from interfering with FastMCP's OAuth
|
||||||
browser_routes = [
|
browser_routes = [
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
@@ -153,8 +154,6 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# Replace internal Docker hostname with public URL
|
# Replace internal Docker hostname with public URL
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -161,8 +162,6 @@ class ClientRegistry:
|
|||||||
True if valid, False otherwise
|
True if valid, False otherwise
|
||||||
"""
|
"""
|
||||||
# Parse the redirect URI
|
# Parse the redirect URI
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
parsed = urlparse(redirect_uri)
|
parsed = urlparse(redirect_uri)
|
||||||
|
|
||||||
# Check against registered patterns
|
# Check against registered patterns
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ import secrets
|
|||||||
import time
|
import time
|
||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
from urllib.parse import urlparse as parse_url
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||||
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
from nextcloud_mcp_server.auth.client_registry import get_client_registry
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
@@ -228,8 +230,6 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
|
||||||
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
|
||||||
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
# Parse internal and authorization endpoint to compare hostnames
|
# Parse internal and authorization endpoint to compare hostnames
|
||||||
@@ -364,8 +364,6 @@ async def oauth_authorize_nextcloud(
|
|||||||
# Fix internal hostname for browser access
|
# Fix internal hostname for browser access
|
||||||
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
|
||||||
if public_issuer:
|
if public_issuer:
|
||||||
from urllib.parse import urlparse as parse_url
|
|
||||||
|
|
||||||
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
internal_parsed = parse_url(oauth_config["nextcloud_host"])
|
||||||
auth_parsed = parse_url(authorization_endpoint)
|
auth_parsed = parse_url(authorization_endpoint)
|
||||||
|
|
||||||
@@ -567,8 +565,6 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from starlette.responses import HTMLResponse
|
|
||||||
|
|
||||||
return HTMLResponse(content=success_html, status_code=200)
|
return HTMLResponse(content=success_html, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
@@ -633,10 +629,6 @@ async def oauth_callback(request: Request):
|
|||||||
elif flow_type == "browser":
|
elif flow_type == "browser":
|
||||||
# Browser UI Login - establish browser session for /user/page access
|
# Browser UI Login - establish browser session for /user/page access
|
||||||
logger.info("Routing to browser login flow")
|
logger.info("Routing to browser login flow")
|
||||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
|
||||||
oauth_login_callback,
|
|
||||||
)
|
|
||||||
|
|
||||||
return await oauth_login_callback(request)
|
return await oauth_login_callback(request)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from mcp.shared.exceptions import McpError
|
|||||||
from mcp.types import ErrorData
|
from mcp.types import ErrorData
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -66,8 +67,6 @@ def require_provisioning(func: Callable) -> Callable:
|
|||||||
|
|
||||||
# Check if we're in token exchange mode - if so, skip provisioning check
|
# Check if we're in token exchange mode - if so, skip provisioning check
|
||||||
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
# In token exchange mode, tokens are exchanged per-request (no stored refresh tokens)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange:
|
||||||
# Token exchange mode - per-request exchange, no provisioning needed
|
# Token exchange mode - per-request exchange, no provisioning needed
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from mcp.server.auth.provider import AccessToken
|
|||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,8 +134,6 @@ def require_scopes(*required_scopes: str):
|
|||||||
# Check if offline access is enabled
|
# Check if offline access is enabled
|
||||||
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
|
||||||
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
enable_offline_access = settings.enable_offline_access
|
enable_offline_access = settings.enable_offline_access
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ from pathlib import Path
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
from anyio import to_thread
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
||||||
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
from nextcloud_mcp_server.observability.metrics import record_db_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -164,10 +168,6 @@ class RefreshTokenStorage:
|
|||||||
|
|
||||||
# Run migrations in a worker thread using anyio.to_thread
|
# Run migrations in a worker thread using anyio.to_thread
|
||||||
# This allows Alembic to run its own async operations in a separate context
|
# This allows Alembic to run its own async operations in a separate context
|
||||||
from anyio import to_thread
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.migrations import stamp_database, upgrade_database
|
|
||||||
|
|
||||||
if not has_alembic:
|
if not has_alembic:
|
||||||
if has_schema:
|
if has_schema:
|
||||||
# Stamp existing database without running migrations
|
# Stamp existing database without running migrations
|
||||||
@@ -1414,6 +1414,69 @@ class RefreshTokenStorage:
|
|||||||
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
logger.debug(f"Found {len(user_ids)} users with app passwords")
|
||||||
return user_ids
|
return user_ids
|
||||||
|
|
||||||
|
async def cleanup_invalid_app_passwords(self, nextcloud_host: str) -> list[str]:
|
||||||
|
"""
|
||||||
|
Validate stored app passwords against Nextcloud and remove invalid ones.
|
||||||
|
|
||||||
|
Makes a lightweight OCS request for each stored user to check if credentials
|
||||||
|
are still valid. Removes entries that return 401/403.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
nextcloud_host: Nextcloud base URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of user IDs whose app passwords were removed
|
||||||
|
"""
|
||||||
|
if not self._initialized:
|
||||||
|
await self.initialize()
|
||||||
|
|
||||||
|
user_ids = await self.get_all_app_password_user_ids()
|
||||||
|
if not user_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
removed: list[str] = []
|
||||||
|
|
||||||
|
async def _validate_user(user_id: str) -> None:
|
||||||
|
app_password = await self.get_app_password(user_id)
|
||||||
|
if not app_password:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
base_url=nextcloud_host,
|
||||||
|
auth=httpx.BasicAuth(user_id, app_password),
|
||||||
|
timeout=10.0,
|
||||||
|
) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"/ocs/v2.php/cloud/user",
|
||||||
|
headers={
|
||||||
|
"OCS-APIRequest": "true",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code in (401, 403):
|
||||||
|
logger.info(
|
||||||
|
f"App password for {user_id} is invalid "
|
||||||
|
f"(HTTP {response.status_code}), removing"
|
||||||
|
)
|
||||||
|
await self.delete_app_password(user_id)
|
||||||
|
removed.append(user_id)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
f"App password for {user_id} validated "
|
||||||
|
f"(HTTP {response.status_code})"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not validate app password for {user_id}: {e}")
|
||||||
|
|
||||||
|
async with anyio.create_task_group() as tg:
|
||||||
|
for user_id in user_ids:
|
||||||
|
tg.start_soon(_validate_user, user_id)
|
||||||
|
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
async def generate_encryption_key() -> str:
|
async def generate_encryption_key() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from httpx import BasicAuth
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
from starlette.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
@@ -56,8 +58,6 @@ async def _get_authenticated_client_for_userinfo(request: Request) -> NextcloudC
|
|||||||
if not all([nextcloud_host, username, password]):
|
if not all([nextcloud_host, username, password]):
|
||||||
raise RuntimeError("BasicAuth credentials not configured")
|
raise RuntimeError("BasicAuth credentials not configured")
|
||||||
|
|
||||||
from httpx import BasicAuth
|
|
||||||
|
|
||||||
assert nextcloud_host is not None
|
assert nextcloud_host is not None
|
||||||
assert username is not None
|
assert username is not None
|
||||||
assert password is not None
|
assert password is not None
|
||||||
@@ -129,7 +129,9 @@ async def _get_processing_status(request: Request) -> dict[str, Any] | None:
|
|||||||
# Get Qdrant client and query indexed count
|
# Get Qdrant client and query indexed count
|
||||||
indexed_count = 0
|
indexed_count = 0
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import ( # noqa: PLC0415
|
||||||
|
get_qdrant_client,
|
||||||
|
)
|
||||||
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
@@ -431,8 +433,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Check if user is admin (for Webhooks tab)
|
# Check if user is admin (for Webhooks tab)
|
||||||
is_admin = False
|
is_admin = False
|
||||||
try:
|
try:
|
||||||
from nextcloud_mcp_server.auth.permissions import is_nextcloud_admin
|
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# Get authenticated Nextcloud client
|
||||||
nc_client = await _get_authenticated_client_for_userinfo(request)
|
nc_client = await _get_authenticated_client_for_userinfo(request)
|
||||||
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
is_admin = await is_nextcloud_admin(request, nc_client._client)
|
||||||
@@ -471,8 +471,6 @@ async def user_info_html(request: Request) -> HTMLResponse:
|
|||||||
# Get Nextcloud host for generating links to apps (used by viz tab)
|
# Get Nextcloud host for generating links to apps (used by viz tab)
|
||||||
# Use public issuer URL if available (for browser-accessible links),
|
# Use public issuer URL if available (for browser-accessible links),
|
||||||
# otherwise fall back to NEXTCLOUD_HOST from settings
|
# otherwise fall back to NEXTCLOUD_HOST from settings
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
nextcloud_host_for_links = (
|
nextcloud_host_for_links = (
|
||||||
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
|
||||||
|
|||||||
@@ -18,16 +18,22 @@ from pathlib import Path
|
|||||||
import anyio
|
import anyio
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
from starlette.authentication import requires
|
from starlette.authentication import requires
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import HTMLResponse, JSONResponse
|
from starlette.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.userinfo_routes import (
|
||||||
|
_get_authenticated_client_for_userinfo,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
from nextcloud_mcp_server.search import (
|
from nextcloud_mcp_server.search import (
|
||||||
BM25HybridSearchAlgorithm,
|
BM25HybridSearchAlgorithm,
|
||||||
SemanticSearchAlgorithm,
|
SemanticSearchAlgorithm,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
from nextcloud_mcp_server.vector.pca import PCA
|
from nextcloud_mcp_server.vector.pca import PCA
|
||||||
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
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
|
||||||
@@ -137,10 +143,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
# Get authenticated HTTP client from session
|
# Get authenticated HTTP client from session
|
||||||
# In BasicAuth mode: uses username/password from session
|
# In BasicAuth mode: uses username/password from session
|
||||||
# In OAuth mode: uses access token from session
|
# In OAuth mode: uses access token from session
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
|
||||||
_get_authenticated_client_for_userinfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
with trace_operation("vector_viz.get_auth_client"):
|
with trace_operation("vector_viz.get_auth_client"):
|
||||||
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
auth_client_ctx = await _get_authenticated_client_for_userinfo(request)
|
||||||
|
|
||||||
@@ -353,8 +355,6 @@ async def vector_visualization_search(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Fallback: generate embedding if not available from search
|
# Fallback: generate embedding if not available from search
|
||||||
from nextcloud_mcp_server.embedding.service import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
query_embedding = await embedding_service.embed(query)
|
query_embedding = await embedding_service.embed(query)
|
||||||
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
logger.info(f"Generated query embedding (dimension={len(query_embedding)})")
|
||||||
@@ -555,11 +555,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
doc_id_int = int(doc_id)
|
doc_id_int = int(doc_id)
|
||||||
|
|
||||||
# Get authenticated Nextcloud client
|
# Get authenticated Nextcloud client
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import (
|
|
||||||
_get_authenticated_client_for_userinfo,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
|
||||||
|
|
||||||
# Use context expansion module to fetch chunk with surrounding context
|
# Use context expansion module to fetch chunk with surrounding context
|
||||||
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
async with await _get_authenticated_client_for_userinfo(request) as nc_client:
|
||||||
chunk_context = await get_chunk_with_context(
|
chunk_context = await get_chunk_with_context(
|
||||||
@@ -594,8 +589,6 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
|
|||||||
page_number = None
|
page_number = None
|
||||||
if doc_type == "file":
|
if doc_type == "file":
|
||||||
try:
|
try:
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
username = request.user.display_name
|
username = request.user.display_name
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import uvicorn
|
|||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
get_settings,
|
get_settings,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.migrations import (
|
||||||
|
create_migration,
|
||||||
|
downgrade_database,
|
||||||
|
get_current_revision,
|
||||||
|
show_migration_history,
|
||||||
|
upgrade_database,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
from nextcloud_mcp_server.observability import get_uvicorn_logging_config
|
||||||
|
|
||||||
from .app import get_app
|
from .app import get_app
|
||||||
@@ -289,8 +296,6 @@ def upgrade(database_path: str, revision: str):
|
|||||||
# Use custom database path
|
# Use custom database path
|
||||||
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
$ nextcloud-mcp-server db upgrade -d /path/to/tokens.db
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import upgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Upgrading database to revision: {revision}")
|
click.echo(f"Upgrading database to revision: {revision}")
|
||||||
upgrade_database(database_path, revision)
|
upgrade_database(database_path, revision)
|
||||||
@@ -335,8 +340,6 @@ def downgrade(database_path: str, revision: str):
|
|||||||
# Downgrade to base (empty database)
|
# Downgrade to base (empty database)
|
||||||
$ nextcloud-mcp-server db downgrade --revision base
|
$ nextcloud-mcp-server db downgrade --revision base
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import downgrade_database
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Downgrading database to revision: {revision}")
|
click.echo(f"Downgrading database to revision: {revision}")
|
||||||
downgrade_database(database_path, revision)
|
downgrade_database(database_path, revision)
|
||||||
@@ -362,8 +365,6 @@ def current(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db current
|
$ nextcloud-mcp-server db current
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import get_current_revision
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
revision = get_current_revision(database_path)
|
revision = get_current_revision(database_path)
|
||||||
if revision:
|
if revision:
|
||||||
@@ -397,8 +398,6 @@ def history(database_path: str):
|
|||||||
Example:
|
Example:
|
||||||
$ nextcloud-mcp-server db history
|
$ nextcloud-mcp-server db history
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import show_migration_history
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo("Migration history:")
|
click.echo("Migration history:")
|
||||||
show_migration_history(database_path)
|
show_migration_history(database_path)
|
||||||
@@ -421,8 +420,6 @@ def migrate(message: str):
|
|||||||
|
|
||||||
Note: You must manually edit the generated migration file to add SQL statements.
|
Note: You must manually edit the generated migration file to add SQL statements.
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.migrations import create_migration
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
click.echo(f"Creating new migration: {message}")
|
click.echo(f"Creating new migration: {message}")
|
||||||
create_migration(message)
|
create_migration(message)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ class NextcloudClient:
|
|||||||
Returns:
|
Returns:
|
||||||
NextcloudClient configured with bearer token authentication
|
NextcloudClient configured with bearer token authentication
|
||||||
"""
|
"""
|
||||||
from ..auth import BearerAuth
|
from ..auth import BearerAuth # noqa: PLC0415
|
||||||
|
|
||||||
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
logger.info(f"Creating NC Client for user '{username}' using OAuth token")
|
||||||
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
return cls(base_url=base_url, username=username, auth=BearerAuth(token))
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import uuid
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from caldav.async_collection import AsyncCalendar
|
from caldav.async_collection import AsyncCalendar, AsyncEvent
|
||||||
from caldav.async_davclient import AsyncDAVClient
|
from caldav.async_davclient import AsyncDAVClient
|
||||||
|
from caldav.elements import cdav, dav
|
||||||
from httpx import Auth
|
from httpx import Auth
|
||||||
from icalendar import Alarm, Calendar, vRecur
|
from icalendar import Alarm, Calendar, vDDDTypes, vRecur
|
||||||
from icalendar import Event as ICalEvent
|
from icalendar import Event as ICalEvent
|
||||||
from icalendar import Todo as ICalTodo
|
from icalendar import Todo as ICalTodo
|
||||||
|
from lxml import etree # type: ignore[import-untyped]
|
||||||
|
|
||||||
from ..config import get_nextcloud_ssl_verify
|
from ..config import get_nextcloud_ssl_verify
|
||||||
|
|
||||||
@@ -103,8 +105,6 @@ class CalendarClient:
|
|||||||
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
# Use custom PROPFIND with CalendarServer namespace (cs:) for calendar-color.
|
||||||
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
# caldav library's nsmap lacks "CS" namespace, and its CalendarColor uses
|
||||||
# Apple iCal namespace which Nextcloud doesn't recognize.
|
# Apple iCal namespace which Nextcloud doesn't recognize.
|
||||||
from lxml import etree # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
propfind_body = """<?xml version="1.0" encoding="utf-8"?>
|
||||||
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
<d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/" xmlns:c="urn:ietf:params:xml:ns:caldav">
|
||||||
<d:prop>
|
<d:prop>
|
||||||
@@ -301,10 +301,6 @@ class CalendarClient:
|
|||||||
end_datetime: Optional[dt.datetime] = None,
|
end_datetime: Optional[dt.datetime] = None,
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Execute a CalDAV REPORT with time-range filter."""
|
"""Execute a CalDAV REPORT with time-range filter."""
|
||||||
from caldav.async_collection import AsyncEvent
|
|
||||||
from caldav.elements import cdav, dav
|
|
||||||
from lxml import etree # type: ignore[import-untyped]
|
|
||||||
|
|
||||||
# Ensure naive datetimes are treated as UTC
|
# Ensure naive datetimes are treated as UTC
|
||||||
if start_datetime and start_datetime.tzinfo is None:
|
if start_datetime and start_datetime.tzinfo is None:
|
||||||
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
start_datetime = start_datetime.replace(tzinfo=dt.UTC)
|
||||||
@@ -889,8 +885,6 @@ class CalendarClient:
|
|||||||
component["DTEND"] = end_dt
|
component["DTEND"] = end_dt
|
||||||
|
|
||||||
# Update timestamps
|
# Update timestamps
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
now = dt.datetime.now(dt.UTC)
|
now = dt.datetime.now(dt.UTC)
|
||||||
component["LAST-MODIFIED"] = vDDDTypes(now)
|
component["LAST-MODIFIED"] = vDDDTypes(now)
|
||||||
component["DTSTAMP"] = vDDDTypes(now)
|
component["DTSTAMP"] = vDDDTypes(now)
|
||||||
@@ -955,24 +949,18 @@ class CalendarClient:
|
|||||||
# Due date
|
# Due date
|
||||||
due = todo_data.get("due", "")
|
due = todo_data.get("due", "")
|
||||||
if due:
|
if due:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
due_dt = self._ensure_timezone_aware(due)
|
due_dt = self._ensure_timezone_aware(due)
|
||||||
todo.add("due", vDDDTypes(due_dt))
|
todo.add("due", vDDDTypes(due_dt))
|
||||||
|
|
||||||
# Start date
|
# Start date
|
||||||
dtstart = todo_data.get("dtstart", "")
|
dtstart = todo_data.get("dtstart", "")
|
||||||
if dtstart:
|
if dtstart:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
start_dt = self._ensure_timezone_aware(dtstart)
|
start_dt = self._ensure_timezone_aware(dtstart)
|
||||||
todo.add("dtstart", vDDDTypes(start_dt))
|
todo.add("dtstart", vDDDTypes(start_dt))
|
||||||
|
|
||||||
# Completed timestamp
|
# Completed timestamp
|
||||||
completed = todo_data.get("completed", "")
|
completed = todo_data.get("completed", "")
|
||||||
if completed:
|
if completed:
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
completed_dt = self._ensure_timezone_aware(completed)
|
completed_dt = self._ensure_timezone_aware(completed)
|
||||||
todo.add("completed", vDDDTypes(completed_dt))
|
todo.add("completed", vDDDTypes(completed_dt))
|
||||||
|
|
||||||
@@ -1061,9 +1049,6 @@ class CalendarClient:
|
|||||||
component["PERCENT-COMPLETE"] = percent_value
|
component["PERCENT-COMPLETE"] = percent_value
|
||||||
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
logger.debug(f"Set PERCENT-COMPLETE to {percent_value}")
|
||||||
|
|
||||||
# Import vDDDTypes at the beginning for datetime formatting
|
|
||||||
from icalendar import vDDDTypes
|
|
||||||
|
|
||||||
# Handle due date
|
# Handle due date
|
||||||
if "due" in todo_data:
|
if "due" in todo_data:
|
||||||
due_str = todo_data["due"]
|
due_str = todo_data["due"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
from httpx import Timeout
|
from httpx import Timeout
|
||||||
|
|
||||||
@@ -164,9 +165,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of matching recipe stubs
|
List of matching recipe stubs
|
||||||
"""
|
"""
|
||||||
# URL encode the query
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_query = quote(query)
|
encoded_query = quote(query)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
"GET", f"/apps/cookbook/api/v1/search/{encoded_query}"
|
||||||
@@ -193,8 +191,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs in the category
|
List of recipe stubs in the category
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_category = quote(category)
|
encoded_category = quote(category)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
"GET", f"/apps/cookbook/api/v1/category/{encoded_category}"
|
||||||
@@ -211,8 +207,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
New category name
|
New category name
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
encoded_old_name = quote(old_name)
|
encoded_old_name = quote(old_name)
|
||||||
response = await self._make_request(
|
response = await self._make_request(
|
||||||
"PUT",
|
"PUT",
|
||||||
@@ -241,8 +235,6 @@ class CookbookClient(BaseNextcloudClient):
|
|||||||
Returns:
|
Returns:
|
||||||
List of recipe stubs matching the keywords
|
List of recipe stubs matching the keywords
|
||||||
"""
|
"""
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
# Join keywords with commas
|
# Join keywords with commas
|
||||||
keywords_str = ",".join(keywords)
|
keywords_str = ",".join(keywords)
|
||||||
encoded_keywords = quote(keywords_str)
|
encoded_keywords = quote(keywords_str)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import logging
|
|||||||
from typing import Any, AsyncIterator, Dict, Optional
|
from typing import Any, AsyncIterator, Dict, Optional
|
||||||
|
|
||||||
from .base import BaseNextcloudClient
|
from .base import BaseNextcloudClient
|
||||||
|
from .webdav import WebDAVClient
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -157,9 +158,6 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
f"Category changed from '{old_note.get('category', '')}' to '{category}' - cleaning up old attachment directory"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
# Import here to avoid circular imports
|
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
await webdav_client.cleanup_old_attachment_directory(
|
await webdav_client.cleanup_old_attachment_directory(
|
||||||
note_id=note_id, old_category=old_note.get("category", "")
|
note_id=note_id, old_category=old_note.get("category", "")
|
||||||
@@ -204,8 +202,6 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
# Clean up attachment directories
|
# Clean up attachment directories
|
||||||
try:
|
try:
|
||||||
from .webdav import WebDAVClient
|
|
||||||
|
|
||||||
webdav_client = WebDAVClient(self._client, self.username)
|
webdav_client = WebDAVClient(self._client, self.username)
|
||||||
|
|
||||||
for cat in potential_categories:
|
for cat in potential_categories:
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
from httpx import HTTPStatusError
|
from httpx import HTTPStatusError
|
||||||
|
|
||||||
@@ -1259,8 +1261,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Decode href path and extract the file path
|
# Decode href path and extract the file path
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
href_path = unquote(href_elem.text)
|
href_path = unquote(href_elem.text)
|
||||||
# Remove WebDAV prefix to get user-relative path
|
# Remove WebDAV prefix to get user-relative path
|
||||||
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
webdav_prefix = f"/remote.php/dav/files/{self.username}/"
|
||||||
@@ -1269,8 +1269,6 @@ class WebDAVClient(BaseNextcloudClient):
|
|||||||
# Parse last modified timestamp
|
# Parse last modified timestamp
|
||||||
last_modified_timestamp = None
|
last_modified_timestamp = None
|
||||||
if lastmodified_elem is not None and lastmodified_elem.text:
|
if lastmodified_elem is not None and lastmodified_elem.text:
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(lastmodified_elem.text)
|
dt = parsedate_to_datetime(lastmodified_elem.text)
|
||||||
last_modified_timestamp = int(dt.timestamp())
|
last_modified_timestamp = int(dt.timestamp())
|
||||||
|
|||||||
@@ -268,9 +268,7 @@ class Settings:
|
|||||||
"This is insecure and should only be used for development/testing."
|
"This is insecure and should only be used for development/testing."
|
||||||
)
|
)
|
||||||
if self.nextcloud_ca_bundle:
|
if self.nextcloud_ca_bundle:
|
||||||
import os as _os
|
if not os.path.isfile(self.nextcloud_ca_bundle):
|
||||||
|
|
||||||
if not _os.path.isfile(self.nextcloud_ca_bundle):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import logging
|
|||||||
from httpx import BasicAuth
|
from httpx import BasicAuth
|
||||||
from mcp.server.fastmcp import Context
|
from mcp.server.fastmcp import Context
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.context_helper import (
|
||||||
|
get_client_from_context,
|
||||||
|
get_session_client_from_context,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import (
|
from nextcloud_mcp_server.config import (
|
||||||
DeploymentMode,
|
DeploymentMode,
|
||||||
@@ -80,11 +84,6 @@ async def get_client(ctx: Context) -> NextcloudClient:
|
|||||||
|
|
||||||
# OAuth mode (has 'nextcloud_host' attribute)
|
# OAuth mode (has 'nextcloud_host' attribute)
|
||||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||||
from nextcloud_mcp_server.auth.context_helper import (
|
|
||||||
get_client_from_context,
|
|
||||||
get_session_client_from_context,
|
|
||||||
)
|
|
||||||
|
|
||||||
if settings.enable_token_exchange:
|
if settings.enable_token_exchange:
|
||||||
# Mode 2: Exchange MCP token for Nextcloud token
|
# Mode 2: Exchange MCP token for Nextcloud token
|
||||||
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
# Token was validated to have MCP audience in UnifiedTokenVerifier
|
||||||
@@ -131,7 +130,7 @@ def _get_client_from_session_config(ctx: Context) -> NextcloudClient:
|
|||||||
ValueError: If required session config fields are missing
|
ValueError: If required session config fields are missing
|
||||||
"""
|
"""
|
||||||
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
# ADR-016: Get session config from context variable (set by SmitheryConfigMiddleware)
|
||||||
from nextcloud_mcp_server.app import get_smithery_session_config
|
from nextcloud_mcp_server.app import get_smithery_session_config # noqa: PLC0415
|
||||||
|
|
||||||
session_config = get_smithery_session_config()
|
session_config = get_smithery_session_config()
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from alembic.config import Config
|
from alembic.config import Config
|
||||||
|
|
||||||
|
import nextcloud_mcp_server.alembic as alembic_package
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -30,8 +31,6 @@ def get_alembic_config(database_path: str | Path | None = None) -> Config:
|
|||||||
Returns:
|
Returns:
|
||||||
Alembic Config object configured for the specified database
|
Alembic Config object configured for the specified database
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server import alembic as alembic_package
|
|
||||||
|
|
||||||
# Use package location (works in both editable and installed modes)
|
# Use package location (works in both editable and installed modes)
|
||||||
if alembic_package.__file__ is None:
|
if alembic_package.__file__ is None:
|
||||||
raise RuntimeError("alembic package __file__ is None")
|
raise RuntimeError("alembic package __file__ is None")
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ class CalendarEventSummary(BaseModel):
|
|||||||
status: Optional[str] = Field(
|
status: Optional[str] = Field(
|
||||||
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
None, description="Event status (CONFIRMED, TENTATIVE, CANCELLED)"
|
||||||
)
|
)
|
||||||
|
calendar_name: Optional[str] = Field(
|
||||||
|
None, description="Calendar containing this event"
|
||||||
|
)
|
||||||
|
calendar_display_name: Optional[str] = Field(
|
||||||
|
None, description="Display name of calendar containing this event"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CalendarEvent(CalendarEventSummary):
|
class CalendarEvent(CalendarEventSummary):
|
||||||
|
|||||||
@@ -261,6 +261,20 @@ class CreateLabelResponse(BaseResponse):
|
|||||||
color: str = Field(description="The created label color")
|
color: str = Field(description="The created label color")
|
||||||
|
|
||||||
|
|
||||||
|
class ListCardsResponse(BaseResponse):
|
||||||
|
"""Response model for listing deck cards."""
|
||||||
|
|
||||||
|
cards: list[DeckCard] = Field(description="List of deck cards")
|
||||||
|
total: int = Field(description="Total number of cards")
|
||||||
|
|
||||||
|
|
||||||
|
class ListLabelsResponse(BaseResponse):
|
||||||
|
"""Response model for listing deck labels."""
|
||||||
|
|
||||||
|
labels: list[DeckLabel] = Field(description="List of deck labels")
|
||||||
|
total: int = Field(description="Total number of labels")
|
||||||
|
|
||||||
|
|
||||||
class LabelOperationResponse(StatusResponse):
|
class LabelOperationResponse(StatusResponse):
|
||||||
"""Response model for label operations like update/delete."""
|
"""Response model for label operations like update/delete."""
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from prometheus_client import (
|
|||||||
start_http_server,
|
start_http_server,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -426,8 +428,6 @@ def instrument_tool(func):
|
|||||||
Wrapped function with metrics and tracing instrumentation
|
Wrapped function with metrics and tracing instrumentation
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
|
||||||
|
|
||||||
@functools.wraps(func)
|
@functools.wraps(func)
|
||||||
async def wrapper(*args, **kwargs):
|
async def wrapper(*args, **kwargs):
|
||||||
tool_name = func.__name__
|
tool_name = func.__name__
|
||||||
|
|||||||
@@ -9,8 +9,12 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pymupdf4llm
|
import pymupdf4llm
|
||||||
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -34,11 +38,6 @@ async def _get_chunk_from_qdrant(
|
|||||||
Full chunk text from Qdrant excerpt field, or None if not found
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -104,11 +103,6 @@ async def _get_chunk_by_index_from_qdrant(
|
|||||||
Full chunk text from Qdrant excerpt field, or None if not found
|
Full chunk text from Qdrant excerpt field, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -165,11 +159,6 @@ async def _get_file_path_from_qdrant(
|
|||||||
File path string, or None if not found in Qdrant
|
File path string, or None if not found in Qdrant
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -225,11 +214,6 @@ async def _get_deck_metadata_from_qdrant(
|
|||||||
Dictionary with board_id and stack_id, or None if not found
|
Dictionary with board_id and stack_id, or None if not found
|
||||||
"""
|
"""
|
||||||
try:
|
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()
|
qdrant_client = await get_qdrant_client()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
@@ -355,8 +339,6 @@ async def get_chunk_with_context(
|
|||||||
|
|
||||||
# Fetch adjacent chunks for context expansion
|
# Fetch adjacent chunks for context expansion
|
||||||
# Get chunk overlap from config to remove duplicate text
|
# Get chunk overlap from config to remove duplicate text
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
chunk_overlap = settings.document_chunk_overlap
|
chunk_overlap = settings.document_chunk_overlap
|
||||||
|
|
||||||
@@ -587,8 +569,6 @@ async def _fetch_document_text(
|
|||||||
return None
|
return None
|
||||||
elif doc_type == "news_item":
|
elif doc_type == "news_item":
|
||||||
# Fetch news item by ID
|
# 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))
|
item = await nc_client.news.get_item(int(doc_id))
|
||||||
# Reconstruct full content as indexed: title + source + URL + body
|
# Reconstruct full content as indexed: title + source + URL + body
|
||||||
# This ensures chunk offsets align with indexed content structure
|
# This ensures chunk offsets align with indexed content structure
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections import defaultdict
|
||||||
|
from io import BytesIO
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import pymupdf4llm
|
import pymupdf4llm
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -682,8 +685,6 @@ class PDFHighlighter:
|
|||||||
# Clean up temp directory and PDF file
|
# Clean up temp directory and PDF file
|
||||||
if temp_pdf_path and temp_pdf_path.parent.exists():
|
if temp_pdf_path and temp_pdf_path.parent.exists():
|
||||||
try:
|
try:
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(temp_pdf_path.parent)
|
shutil.rmtree(temp_pdf_path.parent)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -720,11 +721,6 @@ class PDFHighlighter:
|
|||||||
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
Dict mapping chunk_index to (png_bytes, page_number, highlight_count)
|
||||||
Chunks that fail to highlight are omitted from the result.
|
Chunks that fail to highlight are omitted from the result.
|
||||||
"""
|
"""
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
from collections import defaultdict
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
results: dict[int, tuple[bytes, int, int]] = {}
|
results: dict[int, tuple[bytes, int, int]] = {}
|
||||||
|
|
||||||
if not chunks:
|
if not chunks:
|
||||||
@@ -798,9 +794,6 @@ class PDFHighlighter:
|
|||||||
|
|
||||||
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
# OPTIMIZATION: Render each page ONCE, then draw highlights using PIL
|
||||||
# This avoids expensive page.get_pixmap() calls per chunk
|
# This avoids expensive page.get_pixmap() calls per chunk
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw
|
|
||||||
|
|
||||||
# PIL color for bounding box (RGB tuple)
|
# PIL color for bounding box (RGB tuple)
|
||||||
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
rgb = PDFHighlighter.COLORS.get(color, PDFHighlighter.COLORS["yellow"])
|
||||||
|
|||||||
@@ -9,15 +9,46 @@ from nextcloud_mcp_server.auth import require_scopes
|
|||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.calendar import (
|
from nextcloud_mcp_server.models.calendar import (
|
||||||
Calendar,
|
Calendar,
|
||||||
|
CalendarEventSummary,
|
||||||
ListCalendarsResponse,
|
ListCalendarsResponse,
|
||||||
|
ListEventsResponse,
|
||||||
ListTodosResponse,
|
ListTodosResponse,
|
||||||
Todo,
|
Todo,
|
||||||
|
UpcomingEventsResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_dict_to_summary(event: dict) -> CalendarEventSummary:
|
||||||
|
"""Convert a raw event dict from the calendar client to a CalendarEventSummary."""
|
||||||
|
raw_categories = event.get("categories", [])
|
||||||
|
if isinstance(raw_categories, str):
|
||||||
|
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
|
||||||
|
else:
|
||||||
|
categories = raw_categories
|
||||||
|
|
||||||
|
start = event.get("start_datetime", "")
|
||||||
|
if not start:
|
||||||
|
logger.debug("Event %s has no start_datetime", event.get("uid", "unknown"))
|
||||||
|
|
||||||
|
return CalendarEventSummary(
|
||||||
|
uid=event.get("uid", ""),
|
||||||
|
summary=event.get("title", ""),
|
||||||
|
start=start,
|
||||||
|
end=event.get("end_datetime"),
|
||||||
|
all_day=event.get("all_day", False),
|
||||||
|
location=event.get("location") or None,
|
||||||
|
description=event.get("description") or None,
|
||||||
|
categories=categories,
|
||||||
|
status=event.get("status"),
|
||||||
|
calendar_name=event.get("calendar_name"),
|
||||||
|
calendar_display_name=event.get("calendar_display_name")
|
||||||
|
or event.get("calendar_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_calendar_tools(mcp: FastMCP):
|
def configure_calendar_tools(mcp: FastMCP):
|
||||||
# Calendar tools
|
# Calendar tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -204,7 +235,7 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
filters=filters if filters else None,
|
filters=filters if filters else None,
|
||||||
)
|
)
|
||||||
return events[:limit]
|
events = events[:limit]
|
||||||
else:
|
else:
|
||||||
# Search in specific calendar
|
# Search in specific calendar
|
||||||
events = await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
@@ -214,11 +245,25 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Enrich events with calendar context for per-event mapping.
|
||||||
|
# Note: calendar_display_name is not available here without an
|
||||||
|
# extra list_calendars() call; the response-level calendar_name
|
||||||
|
# already identifies the calendar for single-calendar queries.
|
||||||
|
for event in events:
|
||||||
|
event["calendar_name"] = calendar_name
|
||||||
|
|
||||||
# Apply filters if provided
|
# Apply filters if provided
|
||||||
if filters:
|
if filters:
|
||||||
events = client.calendar._apply_event_filters(events, filters)
|
events = client.calendar._apply_event_filters(events, filters)
|
||||||
|
|
||||||
return events
|
summaries = [_event_dict_to_summary(e) for e in events]
|
||||||
|
return ListEventsResponse(
|
||||||
|
events=summaries,
|
||||||
|
calendar_name=None if search_all_calendars else calendar_name,
|
||||||
|
start_date=start_date or None,
|
||||||
|
end_date=end_date or None,
|
||||||
|
total_found=len(summaries),
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Calendar Event",
|
title="Get Calendar Event",
|
||||||
@@ -420,12 +465,15 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
if calendar_name:
|
if calendar_name:
|
||||||
# Get events from specific calendar
|
# Get events from specific calendar
|
||||||
return await client.calendar.get_calendar_events(
|
events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar_name,
|
calendar_name=calendar_name,
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
|
# calendar_display_name not available without extra API call
|
||||||
|
for event in events:
|
||||||
|
event["calendar_name"] = calendar_name
|
||||||
else:
|
else:
|
||||||
# Get events from all calendars
|
# Get events from all calendars
|
||||||
all_calendars = await client.calendar.list_calendars()
|
all_calendars = await client.calendar.list_calendars()
|
||||||
@@ -433,17 +481,16 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
for calendar in all_calendars:
|
for calendar in all_calendars:
|
||||||
try:
|
try:
|
||||||
events = await client.calendar.get_calendar_events(
|
cal_events = await client.calendar.get_calendar_events(
|
||||||
calendar_name=calendar["name"],
|
calendar_name=calendar["name"],
|
||||||
start_datetime=now,
|
start_datetime=now,
|
||||||
end_datetime=end_datetime,
|
end_datetime=end_datetime,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
)
|
)
|
||||||
# Add calendar info to each event
|
for event in cal_events:
|
||||||
for event in events:
|
|
||||||
event["calendar_name"] = calendar["name"]
|
event["calendar_name"] = calendar["name"]
|
||||||
event["calendar_display_name"] = calendar["display_name"]
|
event["calendar_display_name"] = calendar["display_name"]
|
||||||
all_events.extend(events)
|
all_events.extend(cal_events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"Error getting events from calendar {calendar['name']}: {e}"
|
f"Error getting events from calendar {calendar['name']}: {e}"
|
||||||
@@ -452,7 +499,14 @@ def configure_calendar_tools(mcp: FastMCP):
|
|||||||
|
|
||||||
# Sort by start time and limit
|
# Sort by start time and limit
|
||||||
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
all_events.sort(key=lambda x: x.get("start_datetime", ""))
|
||||||
return all_events[:limit]
|
events = all_events[:limit]
|
||||||
|
|
||||||
|
summaries = [_event_dict_to_summary(e) for e in events]
|
||||||
|
return UpcomingEventsResponse(
|
||||||
|
events=summaries,
|
||||||
|
days_ahead=days_ahead,
|
||||||
|
calendar_name=calendar_name or None,
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Find Availability",
|
title="Find Availability",
|
||||||
|
|||||||
@@ -1,15 +1,57 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server.fastmcp import Context, FastMCP
|
from mcp.server.fastmcp import Context, FastMCP
|
||||||
from mcp.types import ToolAnnotations
|
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
|
||||||
|
from nextcloud_mcp_server.models.contacts import (
|
||||||
|
AddressBook,
|
||||||
|
Contact,
|
||||||
|
ContactField,
|
||||||
|
ListAddressBooksResponse,
|
||||||
|
ListContactsResponse,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||||
|
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||||
|
|
||||||
|
Only maps fields the client's list_contacts() currently returns:
|
||||||
|
fullname, nickname, birthday, and email. Additional Contact model fields
|
||||||
|
(phones, addresses, organization, etc.) require expanding the client's
|
||||||
|
vCard parsing in ContactsClient.list_contacts().
|
||||||
|
"""
|
||||||
|
contact_info = raw.get("contact", {})
|
||||||
|
|
||||||
|
# Convert email field (str, list, or None) to list[ContactField]
|
||||||
|
raw_email = contact_info.get("email")
|
||||||
|
emails: list[ContactField] = []
|
||||||
|
if isinstance(raw_email, list):
|
||||||
|
emails = [ContactField(type="email", value=e) for e in raw_email if e]
|
||||||
|
elif isinstance(raw_email, str) and raw_email:
|
||||||
|
emails = [ContactField(type="email", value=raw_email)]
|
||||||
|
|
||||||
|
# Nickname goes into custom_fields (no dedicated model field)
|
||||||
|
custom_fields: dict[str, Any] = {}
|
||||||
|
nickname = contact_info.get("nickname")
|
||||||
|
if nickname:
|
||||||
|
custom_fields["nickname"] = nickname
|
||||||
|
|
||||||
|
return Contact(
|
||||||
|
uid=raw["vcard_id"],
|
||||||
|
fn=contact_info.get("fullname", ""),
|
||||||
|
etag=raw.get("getetag"),
|
||||||
|
birthday=contact_info.get("birthday"),
|
||||||
|
emails=emails,
|
||||||
|
custom_fields=custom_fields,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_contacts_tools(mcp: FastMCP):
|
def configure_contacts_tools(mcp: FastMCP):
|
||||||
# Contacts tools
|
# Contacts tools
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
@@ -18,10 +60,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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) -> ListAddressBooksResponse:
|
||||||
"""List all addressbooks for the user."""
|
"""List all addressbooks for the user."""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_addressbooks()
|
addressbooks_data = await client.contacts.list_addressbooks()
|
||||||
|
addressbooks = [
|
||||||
|
AddressBook(
|
||||||
|
# ab["name"] is a short slug like "contacts", not a full CardDAV URI;
|
||||||
|
# all tools use it as a path segment: f"{carddav_path}/{name}/"
|
||||||
|
uri=ab["name"],
|
||||||
|
displayname=ab.get("display_name", ab["name"]),
|
||||||
|
ctag=ab.get("getctag"),
|
||||||
|
)
|
||||||
|
for ab in addressbooks_data
|
||||||
|
]
|
||||||
|
return ListAddressBooksResponse(
|
||||||
|
addressbooks=addressbooks, total_count=len(addressbooks)
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="List Contacts",
|
title="List Contacts",
|
||||||
@@ -29,10 +84,16 @@ def configure_contacts_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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
|
||||||
|
) -> ListContactsResponse:
|
||||||
"""List all contacts in the specified addressbook."""
|
"""List all contacts in the specified addressbook."""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.contacts.list_contacts(addressbook=addressbook)
|
contacts_data = await client.contacts.list_contacts(addressbook=addressbook)
|
||||||
|
contacts = [_raw_contact_to_model(c) for c in contacts_data]
|
||||||
|
return ListContactsResponse(
|
||||||
|
contacts=contacts, addressbook=addressbook, total_count=len(contacts)
|
||||||
|
)
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Create Address Book",
|
title="Create Address Book",
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ from nextcloud_mcp_server.models.deck import (
|
|||||||
DeckLabel,
|
DeckLabel,
|
||||||
DeckStack,
|
DeckStack,
|
||||||
LabelOperationResponse,
|
LabelOperationResponse,
|
||||||
|
ListBoardsResponse,
|
||||||
|
ListCardsResponse,
|
||||||
|
ListLabelsResponse,
|
||||||
|
ListStacksResponse,
|
||||||
StackOperationResponse,
|
StackOperationResponse,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
@@ -103,7 +107,7 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return [label.model_dump() for label in board.labels]
|
return [label.model_dump() for label in (board.labels or [])]
|
||||||
|
|
||||||
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
@mcp.resource("nc://Deck/boards/{board_id}/labels/{label_id}")
|
||||||
async def deck_label_resource(board_id: int, label_id: int):
|
async def deck_label_resource(board_id: int, label_id: int):
|
||||||
@@ -124,11 +128,11 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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) -> ListBoardsResponse:
|
||||||
"""Get all Nextcloud Deck boards"""
|
"""Get all Nextcloud Deck boards"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
boards = await client.deck.get_boards()
|
boards = await client.deck.get_boards()
|
||||||
return boards
|
return ListBoardsResponse(boards=boards, total=len(boards))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Board",
|
title="Get Deck Board",
|
||||||
@@ -148,11 +152,11 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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) -> ListStacksResponse:
|
||||||
"""Get all stacks in a Nextcloud Deck board"""
|
"""Get all stacks in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stacks = await client.deck.get_stacks(board_id)
|
stacks = await client.deck.get_stacks(board_id)
|
||||||
return stacks
|
return ListStacksResponse(stacks=stacks, total=len(stacks))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Stack",
|
title="Get Deck Stack",
|
||||||
@@ -174,13 +178,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
@instrument_tool
|
@instrument_tool
|
||||||
async def deck_get_cards(
|
async def deck_get_cards(
|
||||||
ctx: Context, board_id: int, stack_id: int
|
ctx: Context, board_id: int, stack_id: int
|
||||||
) -> list[DeckCard]:
|
) -> ListCardsResponse:
|
||||||
"""Get all cards in a Nextcloud Deck stack"""
|
"""Get all cards in a Nextcloud Deck stack"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
stack = await client.deck.get_stack(board_id, stack_id)
|
stack = await client.deck.get_stack(board_id, stack_id)
|
||||||
if stack.cards:
|
cards = stack.cards or []
|
||||||
return stack.cards
|
return ListCardsResponse(cards=cards, total=len(cards))
|
||||||
return []
|
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Card",
|
title="Get Deck Card",
|
||||||
@@ -202,11 +205,12 @@ def configure_deck_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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) -> ListLabelsResponse:
|
||||||
"""Get all labels in a Nextcloud Deck board"""
|
"""Get all labels in a Nextcloud Deck board"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
board = await client.deck.get_board(board_id)
|
board = await client.deck.get_board(board_id)
|
||||||
return board.labels
|
labels = board.labels or []
|
||||||
|
return ListLabelsResponse(labels=labels, total=len(labels))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Deck Label",
|
title="Get Deck Label",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Nextcloud access using the Flow 2 (Resource Provisioning) OAuth flow.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
@@ -19,9 +20,11 @@ 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
|
||||||
|
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
|
||||||
from ..http import nextcloud_httpx_client
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
@@ -157,11 +160,6 @@ async def get_provisioning_status(ctx: Context, user_id: str) -> ProvisioningSta
|
|||||||
Returns:
|
Returns:
|
||||||
ProvisioningStatus with current provisioning state
|
ProvisioningStatus with current provisioning state
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
|
||||||
# Check for app password first (interim solution)
|
# Check for app password first (interim solution)
|
||||||
@@ -305,8 +303,6 @@ async def provision_nextcloud_access(
|
|||||||
|
|
||||||
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
# Get configuration using settings (handles both ENABLE_BACKGROUND_OPERATIONS
|
||||||
# and ENABLE_OFFLINE_ACCESS environment variables)
|
# and ENABLE_OFFLINE_ACCESS environment variables)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.enable_offline_access:
|
if not settings.enable_offline_access:
|
||||||
return ProvisioningResult(
|
return ProvisioningResult(
|
||||||
@@ -490,8 +486,6 @@ async def check_logged_in(ctx: Context, user_id: Optional[str] = None) -> str:
|
|||||||
|
|
||||||
# Not logged in - generate OAuth URL for Flow 2
|
# Not logged in - generate OAuth URL for Flow 2
|
||||||
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
# Use settings (handles both ENABLE_BACKGROUND_OPERATIONS and ENABLE_OFFLINE_ACCESS)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.enable_offline_access:
|
if not settings.enable_offline_access:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,15 +7,19 @@ from httpx import 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 (
|
from mcp.types import (
|
||||||
|
ClientCapabilities,
|
||||||
ErrorData,
|
ErrorData,
|
||||||
ModelHint,
|
ModelHint,
|
||||||
ModelPreferences,
|
ModelPreferences,
|
||||||
|
SamplingCapability,
|
||||||
SamplingMessage,
|
SamplingMessage,
|
||||||
TextContent,
|
TextContent,
|
||||||
ToolAnnotations,
|
ToolAnnotations,
|
||||||
)
|
)
|
||||||
|
from qdrant_client.models import Filter
|
||||||
|
|
||||||
from nextcloud_mcp_server.auth import require_scopes
|
from nextcloud_mcp_server.auth import require_scopes
|
||||||
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.context import get_client
|
from nextcloud_mcp_server.context import get_client
|
||||||
from nextcloud_mcp_server.models.semantic import (
|
from nextcloud_mcp_server.models.semantic import (
|
||||||
SamplingSearchResponse,
|
SamplingSearchResponse,
|
||||||
@@ -28,6 +32,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
from nextcloud_mcp_server.search.bm25_hybrid import BM25HybridSearchAlgorithm
|
||||||
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
from nextcloud_mcp_server.search.context import get_chunk_with_context
|
||||||
|
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
|
||||||
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -82,8 +88,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
Returns:
|
Returns:
|
||||||
SemanticSearchResponse with matching documents ranked by fusion scores
|
SemanticSearchResponse with matching documents ranked by fusion scores
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
username = client.username
|
username = client.username
|
||||||
@@ -373,8 +377,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 3. Check if client supports sampling
|
# 3. Check if client supports sampling
|
||||||
from mcp.types import ClientCapabilities, SamplingCapability
|
|
||||||
|
|
||||||
client_has_sampling = ctx.session.check_client_capability(
|
client_has_sampling = ctx.session.check_client_capability(
|
||||||
ClientCapabilities(sampling=SamplingCapability())
|
ClientCapabilities(sampling=SamplingCapability())
|
||||||
)
|
)
|
||||||
@@ -658,8 +660,6 @@ def configure_semantic_tools(mcp: FastMCP):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Check if vector sync is enabled (supports both old and new env var names)
|
# Check if vector sync is enabled (supports both old and new env var names)
|
||||||
from nextcloud_mcp_server.config import get_settings
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
if not settings.vector_sync_enabled:
|
if not settings.vector_sync_enabled:
|
||||||
return VectorSyncStatusResponse(
|
return VectorSyncStatusResponse(
|
||||||
@@ -694,15 +694,6 @@ 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.vector.placeholder import (
|
|
||||||
get_placeholder_filter,
|
|
||||||
)
|
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
|
||||||
|
|
||||||
settings = get_settings()
|
|
||||||
qdrant_client = await get_qdrant_client()
|
qdrant_client = await get_qdrant_client()
|
||||||
|
|
||||||
# Count documents in collection, excluding placeholders
|
# Count documents in collection, excluding placeholders
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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
|
||||||
|
from nextcloud_mcp_server.models.tables import ListTablesResponse, Table
|
||||||
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
from nextcloud_mcp_server.observability.metrics import instrument_tool
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -18,10 +19,12 @@ def configure_tables_tools(mcp: FastMCP):
|
|||||||
)
|
)
|
||||||
@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) -> ListTablesResponse:
|
||||||
"""List all tables available to the user"""
|
"""List all tables available to the user"""
|
||||||
client = await get_client(ctx)
|
client = await get_client(ctx)
|
||||||
return await client.tables.list_tables()
|
tables_data = await client.tables.list_tables()
|
||||||
|
tables = [Table(**t) for t in tables_data]
|
||||||
|
return ListTablesResponse(tables=tables, total_count=len(tables))
|
||||||
|
|
||||||
@mcp.tool(
|
@mcp.tool(
|
||||||
title="Get Table Schema",
|
title="Get Table Schema",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ def main():
|
|||||||
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
logger.info("Starting Nextcloud MCP Server in Smithery stateless mode")
|
||||||
|
|
||||||
# Import app after setting environment variables
|
# Import app after setting environment variables
|
||||||
from nextcloud_mcp_server.app import get_app
|
from nextcloud_mcp_server.app import get_app # noqa: PLC0415
|
||||||
|
|
||||||
# Create the app with streamable-http transport (required for Smithery)
|
# Create the app with streamable-http transport (required for Smithery)
|
||||||
app = get_app(transport="streamable-http")
|
app = get_app(transport="streamable-http")
|
||||||
|
|||||||
@@ -31,14 +31,15 @@ from anyio.streams.memory import (
|
|||||||
MemoryObjectReceiveStream,
|
MemoryObjectReceiveStream,
|
||||||
MemoryObjectSendStream,
|
MemoryObjectSendStream,
|
||||||
)
|
)
|
||||||
from httpx import BasicAuth
|
from httpx import BasicAuth, HTTPStatusError
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.vector.processor import process_document
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
from nextcloud_mcp_server.vector.scanner import DocumentTask, scan_user_documents
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -89,8 +90,6 @@ async def get_user_client_basic_auth(
|
|||||||
Raises:
|
Raises:
|
||||||
NotProvisionedError: If user has not provisioned an app password
|
NotProvisionedError: If user has not provisioned an app password
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
|
||||||
|
|
||||||
# Get or create storage instance
|
# Get or create storage instance
|
||||||
if storage is None:
|
if storage is None:
|
||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
@@ -210,9 +209,41 @@ async def user_scanner_task(
|
|||||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
logger.info(f"[{mode_label}] Scanner started for user: {user_id}")
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
max_consecutive_errors = 5
|
||||||
|
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|
||||||
|
# Pre-validate credentials before entering scan loop
|
||||||
|
try:
|
||||||
|
nc_client = await get_user_client(
|
||||||
|
user_id, token_broker, nextcloud_host, use_basic_auth=use_basic_auth
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await nc_client.capabilities() # Lightweight OCS call to validate creds
|
||||||
|
logger.info(f"[{mode_label}] Credentials validated for {user_id}")
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
if e.response.status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Credential validation failed for {user_id} "
|
||||||
|
f"(HTTP {e.response.status_code}), not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await nc_client.close()
|
||||||
|
except NotProvisionedError:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] User {user_id} not provisioned, not starting scan loop"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Pre-validation failed for {user_id}: {e}. "
|
||||||
|
f"Proceeding to scan loop (has its own error handling)."
|
||||||
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
while not shutdown_event.is_set():
|
while not shutdown_event.is_set():
|
||||||
nc_client = None
|
nc_client = None
|
||||||
try:
|
try:
|
||||||
@@ -228,21 +259,64 @@ async def user_scanner_task(
|
|||||||
nc_client=nc_client,
|
nc_client=nc_client,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
consecutive_errors = 0 # Reset on success
|
||||||
|
|
||||||
except NotProvisionedError:
|
except NotProvisionedError:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
f"[{mode_label}] User {user_id} no longer provisioned, stopping scanner"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
except HTTPStatusError as e:
|
||||||
|
status_code = e.response.status_code
|
||||||
|
if status_code in (401, 403):
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner auth failed for {user_id} "
|
||||||
|
f"(HTTP {status_code}), stopping scanner. "
|
||||||
|
f"User may need to re-provision credentials."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
elif status_code == 429:
|
||||||
|
retry_after = min(int(e.response.headers.get("Retry-After", "60")), 300)
|
||||||
|
logger.warning(
|
||||||
|
f"[{mode_label}] Scanner rate-limited for {user_id}, "
|
||||||
|
f"backing off {retry_after}s"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with anyio.move_on_after(retry_after):
|
||||||
|
await shutdown_event.wait()
|
||||||
|
# anyio.get_cancelled_exc_class() catches task cancellation
|
||||||
|
# (e.g. from task group teardown) so we exit cleanly.
|
||||||
|
except anyio.get_cancelled_exc_class():
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
consecutive_errors += 1
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner HTTP error for {user_id}: {e} "
|
||||||
|
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
consecutive_errors += 1
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[{mode_label}] Scanner error for {user_id}: {e}", exc_info=True
|
f"[{mode_label}] Scanner error for {user_id}: {e} "
|
||||||
|
f"({consecutive_errors}/{max_consecutive_errors})",
|
||||||
|
exc_info=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
if nc_client:
|
if nc_client:
|
||||||
await nc_client.close()
|
await nc_client.close()
|
||||||
|
|
||||||
|
if consecutive_errors >= max_consecutive_errors:
|
||||||
|
logger.error(
|
||||||
|
f"[{mode_label}] Scanner for {user_id} hit {max_consecutive_errors} "
|
||||||
|
f"consecutive errors, stopping scanner"
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
# Sleep until next interval or wake event
|
# Sleep until next interval or wake event
|
||||||
try:
|
try:
|
||||||
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
with anyio.move_on_after(settings.vector_sync_scan_interval):
|
||||||
@@ -276,8 +350,6 @@ async def multi_user_processor_task(
|
|||||||
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
use_basic_auth: If True, use app passwords; if False, use OAuth tokens
|
||||||
task_status: Status object for signaling task readiness
|
task_status: Status object for signaling task readiness
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.vector.processor import process_document
|
|
||||||
|
|
||||||
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
mode_label = "BasicAuth" if use_basic_auth else "OAuth"
|
||||||
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
logger.info(f"[{mode_label}] Processor {worker_id} started")
|
||||||
task_status.started()
|
task_status.started()
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from qdrant_client import models
|
||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
@@ -82,8 +83,6 @@ async def write_placeholder_point(
|
|||||||
|
|
||||||
# Create empty sparse vector for placeholders
|
# Create empty sparse vector for placeholders
|
||||||
# Use models.SparseVector with empty indices/values
|
# Use models.SparseVector with empty indices/values
|
||||||
from qdrant_client import models
|
|
||||||
|
|
||||||
empty_sparse = models.SparseVector(indices=[], values=[])
|
empty_sparse = models.SparseVector(indices=[], values=[])
|
||||||
|
|
||||||
# Generate deterministic point ID
|
# Generate deterministic point ID
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue, PointStruct
|
|||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
from nextcloud_mcp_server.embedding import get_bm25_service, get_embedding_service
|
||||||
from nextcloud_mcp_server.observability.metrics import (
|
from nextcloud_mcp_server.observability.metrics import (
|
||||||
record_qdrant_operation,
|
record_qdrant_operation,
|
||||||
@@ -24,7 +25,9 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
update_vector_sync_queue_size,
|
update_vector_sync_queue_size,
|
||||||
)
|
)
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
|
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
||||||
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
from nextcloud_mcp_server.vector.document_chunker import DocumentChunker
|
||||||
|
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
|
||||||
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
from nextcloud_mcp_server.vector.placeholder import delete_placeholder_point
|
||||||
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
|
||||||
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
from nextcloud_mcp_server.vector.scanner import DocumentTask
|
||||||
@@ -275,8 +278,6 @@ async def _index_document(
|
|||||||
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":
|
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))
|
item = await nc_client.news.get_item(int(doc_task.doc_id))
|
||||||
# Convert HTML body to Markdown for better embedding
|
# Convert HTML body to Markdown for better embedding
|
||||||
body_markdown = html_to_markdown(item.get("body", ""))
|
body_markdown = html_to_markdown(item.get("body", ""))
|
||||||
@@ -437,8 +438,6 @@ async def _index_document(
|
|||||||
},
|
},
|
||||||
):
|
):
|
||||||
# Use document processor registry to extract text
|
# Use document processor registry to extract text
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
|
||||||
|
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -586,8 +585,6 @@ async def _index_document(
|
|||||||
"vector_sync.pdf_size": len(content_bytes),
|
"vector_sync.pdf_size": len(content_bytes),
|
||||||
},
|
},
|
||||||
):
|
):
|
||||||
from nextcloud_mcp_server.search.pdf_highlighter import PDFHighlighter
|
|
||||||
|
|
||||||
# Build chunk data for batch processing
|
# Build chunk data for batch processing
|
||||||
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
# Format: (chunk_index, start_offset, end_offset, page_number, chunk_text)
|
||||||
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
chunk_data: list[tuple[int, int, int, int | None, str]] = [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from qdrant_client import AsyncQdrantClient, models
|
|||||||
from qdrant_client.models import Distance, VectorParams
|
from qdrant_client.models import Distance, VectorParams
|
||||||
|
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
|
from nextcloud_mcp_server.embedding import get_embedding_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -62,9 +63,6 @@ async def get_qdrant_client() -> AsyncQdrantClient:
|
|||||||
# Get collection name (auto-generated from deployment ID + model)
|
# Get collection name (auto-generated from deployment ID + model)
|
||||||
collection_name = settings.get_collection_name()
|
collection_name = settings.get_collection_name()
|
||||||
|
|
||||||
# Import here to avoid circular dependency
|
|
||||||
from nextcloud_mcp_server.embedding import get_embedding_service
|
|
||||||
|
|
||||||
embedding_service = get_embedding_service()
|
embedding_service = get_embedding_service()
|
||||||
|
|
||||||
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
# Detect dimension dynamically (for OllamaEmbeddingProvider)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import os
|
|||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from email.utils import parsedate_to_datetime
|
||||||
|
|
||||||
import anyio
|
import anyio
|
||||||
from anyio.abc import TaskStatus
|
from anyio.abc import TaskStatus
|
||||||
@@ -15,6 +16,7 @@ from anyio.streams.memory import MemoryObjectSendStream
|
|||||||
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
from qdrant_client.models import FieldCondition, Filter, MatchValue
|
||||||
|
|
||||||
from nextcloud_mcp_server.client import NextcloudClient
|
from nextcloud_mcp_server.client import NextcloudClient
|
||||||
|
from nextcloud_mcp_server.client.news import NewsItemType
|
||||||
from nextcloud_mcp_server.config import get_settings
|
from nextcloud_mcp_server.config import get_settings
|
||||||
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
from nextcloud_mcp_server.observability.metrics import record_vector_sync_scan
|
||||||
from nextcloud_mcp_server.observability.tracing import trace_operation
|
from nextcloud_mcp_server.observability.tracing import trace_operation
|
||||||
@@ -418,8 +420,6 @@ async def scan_user_documents(
|
|||||||
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
modified_at = file_info.get("last_modified_timestamp", int(time.time()))
|
||||||
if isinstance(file_info.get("last_modified"), str):
|
if isinstance(file_info.get("last_modified"), str):
|
||||||
# Parse RFC 2822 date format if needed
|
# Parse RFC 2822 date format if needed
|
||||||
from email.utils import parsedate_to_datetime
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dt = parsedate_to_datetime(file_info["last_modified"])
|
dt = parsedate_to_datetime(file_info["last_modified"])
|
||||||
modified_at = int(dt.timestamp())
|
modified_at = int(dt.timestamp())
|
||||||
@@ -615,8 +615,6 @@ async def scan_news_items(
|
|||||||
Returns:
|
Returns:
|
||||||
Number of items queued for processing
|
Number of items queued for processing
|
||||||
"""
|
"""
|
||||||
from nextcloud_mcp_server.client.news import NewsItemType
|
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
queued = 0
|
queued = 0
|
||||||
|
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.5"
|
version = "0.64.3"
|
||||||
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.23,<1.24)",
|
"mcp[cli] (>=1.26,<1.27)",
|
||||||
"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)",
|
||||||
@@ -106,7 +106,10 @@ changelog_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:
|
|||||||
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
schema_pattern = "^(feat|fix|docs|refactor|perf|test|build|ci|chore)(?!\\((?:helm)\\))(\\([^)]+\\))?(!)?:\\s.+"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
extend-select = ["I"]
|
extend-select = ["I", "PLC0415"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
"tests/**" = ["PLC0415"]
|
||||||
|
|
||||||
[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" }
|
||||||
|
|||||||
@@ -2400,6 +2400,32 @@ async def test_users_setup(anyio_backend, nc_client: NextcloudClient):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error deleting test user {username}: {e}")
|
logger.warning(f"Error deleting test user {username}: {e}")
|
||||||
|
|
||||||
|
# Clean up all app passwords from MCP server to prevent stale scanners
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"mcp-multi-user-basic",
|
||||||
|
"sqlite3",
|
||||||
|
"/app/data/tokens.db",
|
||||||
|
"DELETE FROM app_passwords;",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to clean up app passwords (rc={result.returncode}): "
|
||||||
|
f"{result.stderr}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info("Cleaned up all test app passwords")
|
||||||
|
|
||||||
|
|
||||||
async def _get_oauth_token_for_user(
|
async def _get_oauth_token_for_user(
|
||||||
browser,
|
browser,
|
||||||
|
|||||||
@@ -834,6 +834,70 @@ async def verify_app_password_created(username: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def clear_stale_test_state(clear_preferences: bool = False) -> None:
|
||||||
|
"""Clear stale app passwords, bruteforce entries, and optionally Astrolabe preferences."""
|
||||||
|
commands: list[tuple[list[str], str]] = [
|
||||||
|
(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"mcp-multi-user-basic",
|
||||||
|
"sqlite3",
|
||||||
|
"/app/data/tokens.db",
|
||||||
|
"DELETE FROM app_passwords;",
|
||||||
|
],
|
||||||
|
"app passwords",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-e",
|
||||||
|
"DELETE FROM oc_bruteforce_attempts;",
|
||||||
|
],
|
||||||
|
"bruteforce entries",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if clear_preferences:
|
||||||
|
commands.append(
|
||||||
|
(
|
||||||
|
[
|
||||||
|
"docker",
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"mariadb",
|
||||||
|
"-u",
|
||||||
|
"root",
|
||||||
|
"-ppassword",
|
||||||
|
"nextcloud",
|
||||||
|
"-e",
|
||||||
|
"DELETE FROM oc_preferences WHERE appid = 'astrolabe';",
|
||||||
|
],
|
||||||
|
"Astrolabe preferences",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for cmd, label in commands:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=10)
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to clear {label} (rc={result.returncode}): {result.stderr}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Cleared {label}")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
@pytest.mark.oauth
|
@pytest.mark.oauth
|
||||||
async def test_multi_user_astrolabe_background_sync_enablement(
|
async def test_multi_user_astrolabe_background_sync_enablement(
|
||||||
@@ -861,6 +925,10 @@ async def test_multi_user_astrolabe_background_sync_enablement(
|
|||||||
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
|
This tests ADR-002 Tier 2 authentication: User-specific app passwords for background operations
|
||||||
in multi-user BasicAuth deployments.
|
in multi-user BasicAuth deployments.
|
||||||
"""
|
"""
|
||||||
|
# Clear stale state from previous test runs
|
||||||
|
logger.info("Clearing stale app passwords and bruteforce entries...")
|
||||||
|
clear_stale_test_state()
|
||||||
|
|
||||||
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||||
await configure_astrolabe_for_mcp_server(
|
await configure_astrolabe_for_mcp_server(
|
||||||
@@ -1198,6 +1266,12 @@ async def test_revoke_background_sync_access(
|
|||||||
This tests the fix for the issue where POST requests to the revoke endpoint
|
This tests the fix for the issue where POST requests to the revoke endpoint
|
||||||
were returning errors due to HTTP method mismatch (was DELETE, now POST).
|
were returning errors due to HTTP method mismatch (was DELETE, now POST).
|
||||||
"""
|
"""
|
||||||
|
# Clear stale state from previous test runs
|
||||||
|
logger.info(
|
||||||
|
"Clearing stale app passwords, bruteforce entries, and Astrolabe preferences..."
|
||||||
|
)
|
||||||
|
clear_stale_test_state(clear_preferences=True)
|
||||||
|
|
||||||
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
# Configure Astrolabe to point to the mcp-multi-user-basic server
|
||||||
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
logger.info("Configuring Astrolabe for mcp-multi-user-basic server...")
|
||||||
await configure_astrolabe_for_mcp_server(
|
await configure_astrolabe_for_mcp_server(
|
||||||
@@ -1218,9 +1292,14 @@ async def test_revoke_background_sync_access(
|
|||||||
# Step 1: Login to Nextcloud
|
# Step 1: Login to Nextcloud
|
||||||
await login_to_nextcloud(page, username, password)
|
await login_to_nextcloud(page, username, password)
|
||||||
|
|
||||||
# Step 2: Generate app password and enable background sync
|
# Step 2: Complete full authorization (OAuth Step 1 + App Password Step 2)
|
||||||
app_password = await generate_app_password(page, username)
|
auth_result = await complete_astrolabe_authorization(page, username, password)
|
||||||
await enable_background_sync_via_app_password(page, username, app_password)
|
assert auth_result["step1"], (
|
||||||
|
f"OAuth authorization (Step 1) failed for {username}"
|
||||||
|
)
|
||||||
|
assert auth_result["step2"], (
|
||||||
|
f"App password setup (Step 2) failed for {username}"
|
||||||
|
)
|
||||||
|
|
||||||
# Step 3: Verify background sync is enabled
|
# Step 3: Verify background sync is enabled
|
||||||
assert await verify_app_password_created(username), (
|
assert await verify_app_password_created(username), (
|
||||||
|
|||||||
@@ -78,11 +78,10 @@ async def test_deck_board_view_permissions(
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
logger.info(f"Bob can see {len(board_list)} boards: {board_ids}")
|
||||||
logger.info(f"Bob can see {len(response_data)} boards: {board_ids}")
|
|
||||||
|
|
||||||
# Bob should see the shared board
|
# Bob should see the shared board
|
||||||
if board_id in board_ids:
|
if board_id in board_ids:
|
||||||
@@ -98,11 +97,10 @@ async def test_deck_board_view_permissions(
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
logger.info(f"Diana can see {len(board_list)} boards")
|
||||||
logger.info(f"Diana can see {len(response_data)} boards")
|
|
||||||
|
|
||||||
# Diana should NOT see the board
|
# Diana should NOT see the board
|
||||||
assert board_id not in board_ids, "Diana should not see board without ACL"
|
assert board_id not in board_ids, "Diana should not see board without ACL"
|
||||||
@@ -313,10 +311,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Alice can see boards: {board_ids}")
|
logger.info(f"Alice can see boards: {board_ids}")
|
||||||
|
|
||||||
# Alice should NOT see Bob's board
|
# Alice should NOT see Bob's board
|
||||||
@@ -332,10 +329,9 @@ async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client):
|
|||||||
|
|
||||||
if not result.isError:
|
if not result.isError:
|
||||||
response_data = json.loads(result.content[0].text)
|
response_data = json.loads(result.content[0].text)
|
||||||
# The response is directly a list of boards
|
# Response is a ListBoardsResponse with a "boards" field
|
||||||
if not isinstance(response_data, list):
|
board_list = response_data.get("boards", [])
|
||||||
response_data = [response_data] if response_data else []
|
board_ids = [b["id"] for b in board_list]
|
||||||
board_ids = [b["id"] for b in response_data]
|
|
||||||
logger.info(f"Bob can see boards: {board_ids}")
|
logger.info(f"Bob can see boards: {board_ids}")
|
||||||
|
|
||||||
# Bob should NOT see Alice's board
|
# Bob should NOT see Alice's board
|
||||||
|
|||||||
+15
-21
@@ -683,17 +683,15 @@ async def test_mcp_calendar_workflow(
|
|||||||
f"MCP list events failed: {list_result.content}"
|
f"MCP list events failed: {list_result.content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
events_data = json.loads(list_result.content[0].text)
|
events_response = json.loads(list_result.content[0].text)
|
||||||
|
|
||||||
# Debug output to understand what nc_calendar_list_events returns
|
# Debug output to understand what nc_calendar_list_events returns
|
||||||
logger.info(f"list_events result type: {type(events_data)}")
|
logger.info(f"list_events result type: {type(events_response)}")
|
||||||
logger.info(f"list_events result content: {events_data}")
|
logger.info(f"list_events result content: {events_response}")
|
||||||
|
|
||||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
|
||||||
if isinstance(events_data, dict):
|
|
||||||
# Single event returned as dict instead of list
|
|
||||||
events_data = [events_data]
|
|
||||||
|
|
||||||
|
# Response is now a ListEventsResponse with an "events" field
|
||||||
|
assert isinstance(events_response, dict), "Expected response dict"
|
||||||
|
events_data = events_response.get("events", [])
|
||||||
assert isinstance(events_data, list), "Expected events list"
|
assert isinstance(events_data, list), "Expected events list"
|
||||||
|
|
||||||
# Our created event should be in the list
|
# Our created event should be in the list
|
||||||
@@ -706,7 +704,7 @@ async def test_mcp_calendar_workflow(
|
|||||||
assert found_event is not None, (
|
assert found_event is not None, (
|
||||||
f"Created event {event_uid} not found in events list"
|
f"Created event {event_uid} not found in events list"
|
||||||
)
|
)
|
||||||
assert found_event["title"] == test_event_title
|
assert found_event["summary"] == test_event_title
|
||||||
|
|
||||||
# 6. Test list events across all calendars
|
# 6. Test list events across all calendars
|
||||||
logger.info("Testing nc_calendar_list_events across all calendars")
|
logger.info("Testing nc_calendar_list_events across all calendars")
|
||||||
@@ -727,13 +725,11 @@ async def test_mcp_calendar_workflow(
|
|||||||
f"MCP list all events failed: {all_list_result.content}"
|
f"MCP list all events failed: {all_list_result.content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
all_events_data = json.loads(all_list_result.content[0].text)
|
all_events_response = json.loads(all_list_result.content[0].text)
|
||||||
|
|
||||||
# Handle single event returned as dict instead of list (same fix as calendars)
|
|
||||||
if isinstance(all_events_data, dict):
|
|
||||||
# Single event returned as dict instead of list
|
|
||||||
all_events_data = [all_events_data]
|
|
||||||
|
|
||||||
|
# Response is now a ListEventsResponse with an "events" field
|
||||||
|
assert isinstance(all_events_response, dict), "Expected response dict"
|
||||||
|
all_events_data = all_events_response.get("events", [])
|
||||||
assert isinstance(all_events_data, list), "Expected events list"
|
assert isinstance(all_events_data, list), "Expected events list"
|
||||||
|
|
||||||
# Our event should still be found when searching all calendars
|
# Our event should still be found when searching all calendars
|
||||||
@@ -780,13 +776,11 @@ async def test_mcp_calendar_workflow(
|
|||||||
f"MCP upcoming events failed: {upcoming_result.content}"
|
f"MCP upcoming events failed: {upcoming_result.content}"
|
||||||
)
|
)
|
||||||
|
|
||||||
upcoming_events = json.loads(upcoming_result.content[0].text)
|
upcoming_response = json.loads(upcoming_result.content[0].text)
|
||||||
|
|
||||||
# Handle single event returned as dict instead of list (same fix as other tools)
|
|
||||||
if isinstance(upcoming_events, dict):
|
|
||||||
# Single event returned as dict instead of list
|
|
||||||
upcoming_events = [upcoming_events]
|
|
||||||
|
|
||||||
|
# Response is now an UpcomingEventsResponse with an "events" field
|
||||||
|
assert isinstance(upcoming_response, dict), "Expected response dict"
|
||||||
|
upcoming_events = upcoming_response.get("events", [])
|
||||||
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
assert isinstance(upcoming_events, list), "Expected upcoming events list"
|
||||||
|
|
||||||
# 10. Delete event via MCP
|
# 10. Delete event via MCP
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.models.contacts import (
|
||||||
|
Contact,
|
||||||
|
ListContactsResponse,
|
||||||
|
)
|
||||||
from nextcloud_mcp_server.models.notes import (
|
from nextcloud_mcp_server.models.notes import (
|
||||||
CreateNoteResponse,
|
CreateNoteResponse,
|
||||||
Note,
|
Note,
|
||||||
@@ -12,6 +16,8 @@ from nextcloud_mcp_server.models.semantic import (
|
|||||||
SamplingSearchResponse,
|
SamplingSearchResponse,
|
||||||
SemanticSearchResult,
|
SemanticSearchResult,
|
||||||
)
|
)
|
||||||
|
from nextcloud_mcp_server.server.calendar import _event_dict_to_summary
|
||||||
|
from nextcloud_mcp_server.server.contacts import _raw_contact_to_model
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
@@ -267,3 +273,218 @@ def test_sampling_search_response_serialization():
|
|||||||
assert data["model_used"] == "claude-3-5-sonnet"
|
assert data["model_used"] == "claude-3-5-sonnet"
|
||||||
assert data["stop_reason"] == "maxTokens"
|
assert data["stop_reason"] == "maxTokens"
|
||||||
assert data["success"] is True
|
assert data["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def _map_contact(raw: dict) -> Contact:
|
||||||
|
"""Thin wrapper around the production mapping function for test readability."""
|
||||||
|
return _raw_contact_to_model(raw)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_preserves_email_birthday_nickname():
|
||||||
|
"""Test that list_contacts mapping preserves email, birthday, and nickname.
|
||||||
|
|
||||||
|
Regression test for PR #574: the original mapping only kept uid, fn, etag
|
||||||
|
and silently dropped email, birthday, and nickname.
|
||||||
|
"""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "abc-123",
|
||||||
|
"getetag": '"etag-val"',
|
||||||
|
"contact": {
|
||||||
|
"fullname": "Jane Doe",
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"birthday": "1990-05-15",
|
||||||
|
"nickname": "JD",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert contact.uid == "abc-123"
|
||||||
|
assert contact.fn == "Jane Doe"
|
||||||
|
assert contact.etag == '"etag-val"'
|
||||||
|
assert contact.birthday == "1990-05-15"
|
||||||
|
assert len(contact.emails) == 1
|
||||||
|
assert contact.emails[0].value == "jane@example.com"
|
||||||
|
assert contact.emails[0].type == "email"
|
||||||
|
assert contact.custom_fields["nickname"] == "JD"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_multiple_emails():
|
||||||
|
"""Test that multiple emails are mapped correctly."""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "def-456",
|
||||||
|
"contact": {
|
||||||
|
"fullname": "John Smith",
|
||||||
|
"email": ["john@work.com", "john@home.com"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert len(contact.emails) == 2
|
||||||
|
assert contact.emails[0].value == "john@work.com"
|
||||||
|
assert contact.emails[1].value == "john@home.com"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_contact_mapping_missing_optional_fields():
|
||||||
|
"""Test mapping when email, birthday, and nickname are absent."""
|
||||||
|
raw_contact = {
|
||||||
|
"vcard_id": "ghi-789",
|
||||||
|
"contact": {"fullname": "No Details"},
|
||||||
|
}
|
||||||
|
|
||||||
|
contact = _map_contact(raw_contact)
|
||||||
|
|
||||||
|
assert contact.uid == "ghi-789"
|
||||||
|
assert contact.fn == "No Details"
|
||||||
|
assert contact.birthday is None
|
||||||
|
assert contact.emails == []
|
||||||
|
assert contact.custom_fields == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_list_contacts_response_wraps_contacts():
|
||||||
|
"""Test ListContactsResponse wraps contacts correctly for MCP output."""
|
||||||
|
contacts = [
|
||||||
|
_map_contact(
|
||||||
|
{
|
||||||
|
"vcard_id": "a",
|
||||||
|
"getetag": '"e1"',
|
||||||
|
"contact": {
|
||||||
|
"fullname": "Alice",
|
||||||
|
"email": "alice@test.com",
|
||||||
|
"birthday": "2000-01-01",
|
||||||
|
"nickname": "Ali",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
response = ListContactsResponse(
|
||||||
|
contacts=contacts, addressbook="personal", total_count=1
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.model_dump()
|
||||||
|
assert data["total_count"] == 1
|
||||||
|
assert len(data["contacts"]) == 1
|
||||||
|
c = data["contacts"][0]
|
||||||
|
assert c["birthday"] == "2000-01-01"
|
||||||
|
assert c["emails"][0]["value"] == "alice@test.com"
|
||||||
|
assert c["custom_fields"]["nickname"] == "Ali"
|
||||||
|
|
||||||
|
|
||||||
|
# ============= _event_dict_to_summary tests =============
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_basic():
|
||||||
|
"""Test basic mapping with all fields populated."""
|
||||||
|
event = {
|
||||||
|
"uid": "evt-001",
|
||||||
|
"title": "Team Standup",
|
||||||
|
"start_datetime": "2025-07-28T09:00:00",
|
||||||
|
"end_datetime": "2025-07-28T09:30:00",
|
||||||
|
"all_day": False,
|
||||||
|
"location": "Room 42",
|
||||||
|
"description": "Daily sync",
|
||||||
|
"categories": ["work", "meeting"],
|
||||||
|
"status": "CONFIRMED",
|
||||||
|
"calendar_name": "office",
|
||||||
|
"calendar_display_name": "Office Calendar",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.uid == "evt-001"
|
||||||
|
assert summary.summary == "Team Standup"
|
||||||
|
assert summary.start == "2025-07-28T09:00:00"
|
||||||
|
assert summary.end == "2025-07-28T09:30:00"
|
||||||
|
assert summary.all_day is False
|
||||||
|
assert summary.location == "Room 42"
|
||||||
|
assert summary.description == "Daily sync"
|
||||||
|
assert summary.categories == ["work", "meeting"]
|
||||||
|
assert summary.status == "CONFIRMED"
|
||||||
|
assert summary.calendar_name == "office"
|
||||||
|
assert summary.calendar_display_name == "Office Calendar"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_categories_string():
|
||||||
|
"""Test that comma-separated category string is split into a list."""
|
||||||
|
event = {
|
||||||
|
"uid": "evt-002",
|
||||||
|
"title": "Review",
|
||||||
|
"categories": "work, meeting, important",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.categories == ["work", "meeting", "important"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_categories_list_passthrough():
|
||||||
|
"""Test that a list of categories passes through unchanged."""
|
||||||
|
event = {
|
||||||
|
"uid": "evt-003",
|
||||||
|
"title": "Review",
|
||||||
|
"categories": ["personal", "health"],
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.categories == ["personal", "health"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_falsy_location_description():
|
||||||
|
"""Test that empty/falsy location and description are coerced to None."""
|
||||||
|
event = {
|
||||||
|
"uid": "evt-004",
|
||||||
|
"title": "Quick Chat",
|
||||||
|
"location": "",
|
||||||
|
"description": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.location is None
|
||||||
|
assert summary.description is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_missing_optional_fields():
|
||||||
|
"""Test mapping with only required fields present."""
|
||||||
|
event = {"uid": "evt-005", "title": "Minimal Event"}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.uid == "evt-005"
|
||||||
|
assert summary.summary == "Minimal Event"
|
||||||
|
assert summary.start == ""
|
||||||
|
assert summary.end is None
|
||||||
|
assert summary.all_day is False
|
||||||
|
assert summary.location is None
|
||||||
|
assert summary.description is None
|
||||||
|
assert summary.categories == []
|
||||||
|
assert summary.status is None
|
||||||
|
assert summary.calendar_name is None
|
||||||
|
assert summary.calendar_display_name is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_event_dict_to_summary_calendar_name_without_display_name():
|
||||||
|
"""Test single-calendar path: calendar_name set, display_name absent falls back."""
|
||||||
|
event = {
|
||||||
|
"uid": "evt-006",
|
||||||
|
"title": "Personal Errand",
|
||||||
|
"calendar_name": "personal",
|
||||||
|
}
|
||||||
|
|
||||||
|
summary = _event_dict_to_summary(event)
|
||||||
|
|
||||||
|
assert summary.calendar_name == "personal"
|
||||||
|
assert summary.calendar_display_name == "personal"
|
||||||
|
|||||||
@@ -1697,7 +1697,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.23.2"
|
version = "1.26.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -1715,9 +1715,9 @@ dependencies = [
|
|||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/39/a9/0e95530946408747ae200e86553ceda0dbd851d4ae9bbe0d02a69cbd6ad5/mcp-1.23.2.tar.gz", hash = "sha256:df4e4b7273dca2aaf428f9cf7a25bbac0c9007528a65004854b246aef3d157bc", size = 599953, upload-time = "2025-12-08T15:51:02.432Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/6a/1a726905cf41a69d00989e8dfd9de7bd9b4a9f3c8723dac3077b0ba1a7b9/mcp-1.23.2-py3-none-any.whl", hash = "sha256:d8e4c6af0317ad954ea0a53dfb5e229dddea2d0a54568c080e82e8fae4a8264e", size = 231897, upload-time = "2025-12-08T15:51:01.023Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
@@ -1988,7 +1988,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.5"
|
version = "0.64.3"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
@@ -2055,7 +2055,7 @@ requires-dist = [
|
|||||||
{ name = "jinja2", specifier = ">=3.1.6" },
|
{ name = "jinja2", specifier = ">=3.1.6" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
{ name = "langchain-text-splitters", specifier = ">=1.0.0" },
|
||||||
{ name = "markdownify", specifier = ">=0.14.1" },
|
{ name = "markdownify", specifier = ">=0.14.1" },
|
||||||
{ name = "mcp", extras = ["cli"], specifier = ">=1.23,<1.24" },
|
{ name = "mcp", extras = ["cli"], specifier = ">=1.26,<1.27" },
|
||||||
{ name = "openai", specifier = ">=2.8.1" },
|
{ name = "openai", specifier = ">=2.8.1" },
|
||||||
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
|
{ name = "opentelemetry-api", specifier = ">=1.28.2" },
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
|
{ name = "opentelemetry-exporter-otlp-proto-grpc", specifier = ">=1.28.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user