Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aa21663b7 | |||
| d145e4d5de | |||
| cf4ed4a641 | |||
| 8d84d95ada | |||
| 992d380585 | |||
| 2657071404 | |||
| 75325f16fc | |||
| 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 | |||
| 66ccacdee1 | |||
| 1a4486a388 | |||
| 91d06acfb4 | |||
| 90874ca7cd | |||
| da8fed3382 | |||
| 8963e65f1b | |||
| 75c3868e74 | |||
| 1707b2e6e1 | |||
| df3cce4370 | |||
| 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@2f8ba26a219c06cfb0f468eef8d97055fa814f97 # v1.0.53
|
||||||
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@2f8ba26a219c06cfb0f468eef8d97055fa814f97 # v1.0.53
|
||||||
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@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||||
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@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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,23 @@ 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.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.48"
|
version = "0.57.63"
|
||||||
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,54 @@ 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.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)
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- remove stale astrolabe references from commitizen config
|
||||||
|
- extract Astrolabe to separate repository
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
## nextcloud-mcp-server-0.57.48 (2026-02-15)
|
||||||
|
|
||||||
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
## nextcloud-mcp-server-0.57.47 (2026-02-15)
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ dependencies:
|
|||||||
version: 1.16.3
|
version: 1.16.3
|
||||||
- name: ollama
|
- name: ollama
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
version: 1.42.0
|
version: 1.43.0
|
||||||
digest: sha256:a9aef6e290f23b1ed961450e0635eb0bce42f8c52805276901a80df7c27473f6
|
digest: sha256:533eda3fdb4bd92cdafee49bd7b0428fc87d21b509032442c04ed645900e464a
|
||||||
generated: "2026-02-10T11:10:44.457881902Z"
|
generated: "2026-02-16T11:16:41.257136832Z"
|
||||||
|
|||||||
@@ -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.48
|
version: 0.57.63
|
||||||
appVersion: "0.63.5"
|
appVersion: "0.64.1"
|
||||||
keywords:
|
keywords:
|
||||||
- nextcloud
|
- nextcloud
|
||||||
- mcp
|
- mcp
|
||||||
@@ -31,6 +31,6 @@ dependencies:
|
|||||||
repository: https://qdrant.github.io/qdrant-helm
|
repository: https://qdrant.github.io/qdrant-helm
|
||||||
condition: qdrant.networkMode.deploySubchart
|
condition: qdrant.networkMode.deploySubchart
|
||||||
- name: ollama
|
- name: ollama
|
||||||
version: "1.42.0"
|
version: "1.43.0"
|
||||||
repository: https://otwld.github.io/ollama-helm
|
repository: https://otwld.github.io/ollama-helm
|
||||||
condition: ollama.enabled
|
condition: ollama.enabled
|
||||||
|
|||||||
+3
-3
@@ -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:fd83658b0e40e2164617d262f13c02ca9ee9e1e6b276fd2fa06617e09bd5c780
|
||||||
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.3@sha256:5a236ae4dd8ece77490115bace15a11a4d15e9cbcf58a490b95a7da2cd71d32a
|
||||||
command:
|
command:
|
||||||
- "start-dev"
|
- "start-dev"
|
||||||
- "--import-realm"
|
- "--import-realm"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -171,6 +171,58 @@ NEXTCLOUD_PASSWORD=your_app_password_or_password
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## SSL/TLS Configuration (Optional)
|
||||||
|
|
||||||
|
If your Nextcloud instance uses a self-signed certificate or a private CA (common with reverse proxies like Traefik or Caddy), the MCP server will reject the connection by default. Use these settings to configure certificate verification.
|
||||||
|
|
||||||
|
### Custom CA Bundle (Recommended)
|
||||||
|
|
||||||
|
Point the server at your CA certificate file:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
With Docker, mount the certificate as a read-only volume:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run \
|
||||||
|
-v /path/to/my-ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||||
|
-e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem \
|
||||||
|
-e NEXTCLOUD_HOST=https://nextcloud.local \
|
||||||
|
--env-file .env \
|
||||||
|
ghcr.io/cbcoutinho/nextcloud-mcp-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### Disable Verification (Development Only)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> Disabling TLS verification is insecure. Only use this for local development or testing.
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NEXTCLOUD_VERIFY_SSL=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Required | Default | Description |
|
||||||
|
|----------|----------|---------|-------------|
|
||||||
|
| `NEXTCLOUD_VERIFY_SSL` | ⚠️ Optional | `true` | Set to `false` to disable TLS certificate verification |
|
||||||
|
| `NEXTCLOUD_CA_BUNDLE` | ⚠️ Optional | - | Path to a PEM CA bundle file for custom certificate authorities |
|
||||||
|
|
||||||
|
### Scope
|
||||||
|
|
||||||
|
These settings apply to **all** outbound connections to Nextcloud and its OIDC endpoints, including:
|
||||||
|
|
||||||
|
- Nextcloud API calls (Notes, Calendar, Contacts, WebDAV, etc.)
|
||||||
|
- OIDC discovery and token endpoints
|
||||||
|
- OAuth client registration (DCR)
|
||||||
|
- Health checks
|
||||||
|
|
||||||
|
They do **not** affect connections to internal services (Ollama, Qdrant, Unstructured) which have their own SSL configuration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Semantic Search Configuration (Optional)
|
## Semantic Search Configuration (Optional)
|
||||||
|
|
||||||
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
**New in v0.58.0:** Simplified semantic search configuration with automatic dependency resolution.
|
||||||
|
|||||||
+13
@@ -217,6 +217,19 @@ NEXTCLOUD_PASSWORD=
|
|||||||
#CUSTOM_PROCESSOR_TIMEOUT=60
|
#CUSTOM_PROCESSOR_TIMEOUT=60
|
||||||
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
#CUSTOM_PROCESSOR_TYPES=application/pdf,image/jpeg,image/png
|
||||||
|
|
||||||
|
# ===== SSL/TLS =====
|
||||||
|
# For Nextcloud behind reverse proxies with self-signed or private CA certificates
|
||||||
|
#
|
||||||
|
# Disable TLS certificate verification (insecure, development only):
|
||||||
|
#NEXTCLOUD_VERIFY_SSL=false
|
||||||
|
#
|
||||||
|
# Use a custom CA bundle (path to PEM file):
|
||||||
|
#NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem
|
||||||
|
#
|
||||||
|
# Docker example: mount the CA bundle as a volume
|
||||||
|
# docker run -v /path/to/ca.pem:/etc/ssl/certs/my-ca.pem:ro \
|
||||||
|
# -e NEXTCLOUD_CA_BUNDLE=/etc/ssl/certs/my-ca.pem ...
|
||||||
|
|
||||||
# ===== SECURITY & ADVANCED =====
|
# ===== SECURITY & ADVANCED =====
|
||||||
# Cookie security (browser UI)
|
# Cookie security (browser UI)
|
||||||
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
# Auto-detects from NEXTCLOUD_HOST protocol if not set
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ 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:
|
if TYPE_CHECKING:
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
@@ -252,7 +254,9 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
|||||||
|
|
||||||
# Validate app password against Nextcloud
|
# Validate app password against Nextcloud
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
async with nextcloud_httpx_client(
|
||||||
|
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||||
|
) as client:
|
||||||
# Use OCS API to verify credentials
|
# Use OCS API to verify credentials
|
||||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@@ -380,7 +384,9 @@ async def delete_app_password(request: Request) -> JSONResponse:
|
|||||||
nextcloud_host = settings.nextcloud_host
|
nextcloud_host = settings.nextcloud_host
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=NEXTCLOUD_VALIDATION_TIMEOUT) as client:
|
async with nextcloud_httpx_client(
|
||||||
|
timeout=NEXTCLOUD_VALIDATION_TIMEOUT
|
||||||
|
) as client:
|
||||||
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
test_url = f"{nextcloud_host}/ocs/v1.php/cloud/user"
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
test_url,
|
test_url,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ All endpoints require OAuth bearer token authentication via UnifiedTokenVerifier
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
import httpx
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
@@ -20,6 +19,8 @@ from nextcloud_mcp_server.api.management import (
|
|||||||
validate_token_and_get_user,
|
validate_token_and_get_user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,7 +58,7 @@ async def get_installed_apps(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -129,7 +130,7 @@ async def list_webhooks(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -210,7 +211,7 @@ async def create_webhook(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -286,7 +287,7 @@ async def delete_webhook(request: Request) -> JSONResponse:
|
|||||||
raise ValueError("Nextcloud host not configured")
|
raise ValueError("Nextcloud host not configured")
|
||||||
|
|
||||||
# Create authenticated HTTP client
|
# Create authenticated HTTP client
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {token}"},
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ from nextcloud_mcp_server.config_validators import (
|
|||||||
)
|
)
|
||||||
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
from nextcloud_mcp_server.context import get_client as get_nextcloud_client
|
||||||
from nextcloud_mcp_server.document_processors import get_registry
|
from nextcloud_mcp_server.document_processors import get_registry
|
||||||
|
from nextcloud_mcp_server.http import nextcloud_httpx_client
|
||||||
from nextcloud_mcp_server.observability import (
|
from nextcloud_mcp_server.observability import (
|
||||||
ObservabilityMiddleware,
|
ObservabilityMiddleware,
|
||||||
setup_metrics,
|
setup_metrics,
|
||||||
@@ -690,7 +691,7 @@ async def setup_oauth_config():
|
|||||||
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
logger.info(f"Performing OIDC discovery: {discovery_url}")
|
||||||
|
|
||||||
# Perform OIDC discovery
|
# Perform OIDC discovery
|
||||||
async with httpx.AsyncClient(follow_redirects=True) as client:
|
async with nextcloud_httpx_client(follow_redirects=True) as client:
|
||||||
response = await client.get(discovery_url)
|
response = await client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -994,7 +995,7 @@ async def setup_oauth_config_for_multi_user_basic(
|
|||||||
|
|
||||||
# Perform OIDC discovery
|
# Perform OIDC discovery
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with nextcloud_httpx_client(
|
||||||
timeout=30.0, follow_redirects=True
|
timeout=30.0, follow_redirects=True
|
||||||
) as http_client:
|
) as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
@@ -1975,7 +1976,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
|
|||||||
# Try to connect to Nextcloud
|
# Try to connect to Nextcloud
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=2.0) as client:
|
async with nextcloud_httpx_client(timeout=2.0) as client:
|
||||||
response = await client.get(f"{nextcloud_host}/status.php")
|
response = await client.get(f"{nextcloud_host}/status.php")
|
||||||
duration = time.time() - start_time
|
duration = time.time() - start_time
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import httpx
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ class AstrolabeClient:
|
|||||||
# Discover token endpoint
|
# Discover token endpoint
|
||||||
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
discovery_url = f"{self.nextcloud_host}/.well-known/openid-configuration"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with nextcloud_httpx_client() as client:
|
||||||
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
logger.debug(f"Discovering token endpoint from {discovery_url}")
|
||||||
discovery_resp = await client.get(discovery_url)
|
discovery_resp = await client.get(discovery_url)
|
||||||
discovery_resp.raise_for_status()
|
discovery_resp.raise_for_status()
|
||||||
@@ -107,7 +107,7 @@ class AstrolabeClient:
|
|||||||
token = await self.get_access_token()
|
token = await self.get_access_token()
|
||||||
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
url = f"{self.nextcloud_host}/apps/astrolabe/api/v1/background-sync/credentials/{user_id}"
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
async with nextcloud_httpx_client() as client:
|
||||||
logger.debug(f"Retrieving app password for user: {user_id}")
|
logger.debug(f"Retrieving app password for user: {user_id}")
|
||||||
|
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
|
|||||||
_query_idp_userinfo,
|
_query_idp_userinfo,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +144,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint
|
# Fetch authorization endpoint
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -286,7 +288,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
oauth_client.token_endpoint,
|
oauth_client.token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
@@ -296,7 +298,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
else:
|
else:
|
||||||
# Integrated mode (Nextcloud OIDC)
|
# Integrated mode (Nextcloud OIDC)
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -314,7 +316,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
if code_verifier:
|
if code_verifier:
|
||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import httpx
|
|||||||
|
|
||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ async def register_client(
|
|||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with nextcloud_httpx_client(timeout=30.0) as client:
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
registration_endpoint,
|
registration_endpoint,
|
||||||
@@ -229,7 +231,7 @@ async def delete_client(
|
|||||||
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
|
||||||
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
async with nextcloud_httpx_client(timeout=30.0) as http_client:
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
try:
|
try:
|
||||||
# Prefer RFC 7592 Bearer token authentication
|
# Prefer RFC 7592 Bearer token authentication
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ class KeycloakOAuthClient:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client"""
|
"""Get or create HTTP client"""
|
||||||
if self._http_client is None:
|
if self._http_client is None:
|
||||||
self._http_client = httpx.AsyncClient(timeout=30.0)
|
self._http_client = nextcloud_httpx_client(timeout=30.0)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
|||||||
@@ -27,7 +27,6 @@ import time
|
|||||||
from base64 import urlsafe_b64encode
|
from base64 import urlsafe_b64encode
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
import jwt
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, RedirectResponse
|
from starlette.responses import JSONResponse, RedirectResponse
|
||||||
@@ -35,6 +34,8 @@ from starlette.responses import JSONResponse, RedirectResponse
|
|||||||
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
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -218,7 +219,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Fetch authorization endpoint from discovery
|
# Fetch authorization endpoint from discovery
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -354,7 +355,7 @@ async def oauth_authorize_nextcloud(
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -462,7 +463,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
callback_uri = f"{mcp_server_url}/oauth/callback"
|
callback_uri = f"{mcp_server_url}/oauth/callback"
|
||||||
|
|
||||||
discovery_url = oauth_config.get("discovery_url")
|
discovery_url = oauth_config.get("discovery_url")
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.get(discovery_url)
|
response = await http_client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -482,7 +483,7 @@ async def oauth_callback_nextcloud(request: Request):
|
|||||||
token_params["code_verifier"] = code_verifier
|
token_params["code_verifier"] = code_verifier
|
||||||
|
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
response = await http_client.post(
|
response = await http_client.post(
|
||||||
token_endpoint,
|
token_endpoint,
|
||||||
data=token_params,
|
data=token_params,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import jwt
|
|||||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||||
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -136,7 +138,7 @@ class TokenBrokerService:
|
|||||||
async def _get_http_client(self) -> httpx.AsyncClient:
|
async def _get_http_client(self) -> httpx.AsyncClient:
|
||||||
"""Get or create HTTP client."""
|
"""Get or create HTTP client."""
|
||||||
if self._http_client is None:
|
if self._http_client is None:
|
||||||
self._http_client = httpx.AsyncClient(
|
self._http_client = nextcloud_httpx_client(
|
||||||
timeout=httpx.Timeout(30.0), follow_redirects=True
|
timeout=httpx.Timeout(30.0), follow_redirects=True
|
||||||
)
|
)
|
||||||
return self._http_client
|
return self._http_client
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import httpx
|
|||||||
import jwt
|
import jwt
|
||||||
|
|
||||||
from ..config import get_settings
|
from ..config import get_settings
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
from .storage import RefreshTokenStorage
|
from .storage import RefreshTokenStorage
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -68,7 +69,7 @@ class TokenExchangeService:
|
|||||||
self.storage: Optional[RefreshTokenStorage] = None
|
self.storage: Optional[RefreshTokenStorage] = None
|
||||||
|
|
||||||
# Create HTTP client
|
# Create HTTP client
|
||||||
self.http_client = httpx.AsyncClient(
|
self.http_client = nextcloud_httpx_client(
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ from nextcloud_mcp_server.observability.metrics import (
|
|||||||
record_oauth_token_validation,
|
record_oauth_token_validation,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,7 +63,7 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
self.mode = "exchange" if settings.enable_token_exchange else "multi-audience"
|
||||||
|
|
||||||
# Common components for all modes
|
# Common components for all modes
|
||||||
self.http_client = httpx.AsyncClient(timeout=10.0)
|
self.http_client = nextcloud_httpx_client(timeout=10.0)
|
||||||
|
|
||||||
# JWT verification support
|
# JWT verification support
|
||||||
self.jwks_client: PyJWKClient | None = None
|
self.jwks_client: PyJWKClient | None = None
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import traceback
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
|
||||||
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
|
||||||
@@ -22,6 +21,8 @@ from starlette.responses import HTMLResponse, JSONResponse
|
|||||||
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 ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Setup Jinja2 environment for templates
|
# Setup Jinja2 environment for templates
|
||||||
@@ -257,7 +258,7 @@ async def _get_userinfo_endpoint(oauth_ctx: dict[str, Any]) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||||
response = await client.get(discovery_url)
|
response = await client.get(discovery_url)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
discovery = response.json()
|
discovery = response.json()
|
||||||
@@ -290,7 +291,7 @@ async def _query_idp_userinfo(
|
|||||||
User info dictionary from IdP, or None if query fails
|
User info dictionary from IdP, or None if query fails
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with nextcloud_httpx_client(timeout=10.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
userinfo_uri,
|
userinfo_uri,
|
||||||
headers={"Authorization": f"Bearer {access_token_str}"},
|
headers={"Authorization": f"Bearer {access_token_str}"},
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ from nextcloud_mcp_server.server.webhook_presets import (
|
|||||||
get_preset,
|
get_preset,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,7 +142,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
|
|
||||||
assert nextcloud_host is not None # Type narrowing for type checker
|
assert nextcloud_host is not None # Type narrowing for type checker
|
||||||
assert username is not None and password is not None # Type narrowing
|
assert username is not None and password is not None # Type narrowing
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
auth=(username, password),
|
auth=(username, password),
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
@@ -163,7 +165,7 @@ async def _get_authenticated_client(request: Request) -> httpx.AsyncClient:
|
|||||||
if not nextcloud_host:
|
if not nextcloud_host:
|
||||||
raise RuntimeError("Nextcloud host not configured")
|
raise RuntimeError("Nextcloud host not configured")
|
||||||
|
|
||||||
return httpx.AsyncClient(
|
return nextcloud_httpx_client(
|
||||||
base_url=nextcloud_host,
|
base_url=nextcloud_host,
|
||||||
headers={"Authorization": f"Bearer {access_token}"},
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
timeout=30.0,
|
timeout=30.0,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import os
|
|||||||
from httpx import (
|
from httpx import (
|
||||||
AsyncBaseTransport,
|
AsyncBaseTransport,
|
||||||
AsyncClient,
|
AsyncClient,
|
||||||
AsyncHTTPTransport,
|
|
||||||
Auth,
|
Auth,
|
||||||
BasicAuth,
|
BasicAuth,
|
||||||
Request,
|
Request,
|
||||||
@@ -13,6 +12,7 @@ from httpx import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from ..controllers.notes_search import NotesSearchController
|
from ..controllers.notes_search import NotesSearchController
|
||||||
|
from ..http import nextcloud_httpx_transport
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
from .contacts import ContactsClient
|
from .contacts import ContactsClient
|
||||||
from .cookbook import CookbookClient
|
from .cookbook import CookbookClient
|
||||||
@@ -67,7 +67,7 @@ class NextcloudClient:
|
|||||||
self._client = AsyncClient(
|
self._client = AsyncClient(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
transport=AsyncDisableCookieTransport(nextcloud_httpx_transport()),
|
||||||
event_hooks={"request": [log_request], "response": [log_response]},
|
event_hooks={"request": [log_request], "response": [log_response]},
|
||||||
timeout=Timeout(timeout=30, connect=5),
|
timeout=Timeout(timeout=30, connect=5),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from icalendar import Alarm, Calendar, 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 ..config import get_nextcloud_ssl_verify
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +36,7 @@ class CalendarClient:
|
|||||||
url=f"{base_url}/remote.php/dav/",
|
url=f"{base_url}/remote.php/dav/",
|
||||||
username=username,
|
username=username,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
|
ssl_verify_cert=get_nextcloud_ssl_verify(), # type: ignore[arg-type] # caldav types say bool|str but passes through to httpx which accepts SSLContext
|
||||||
)
|
)
|
||||||
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
self._calendar_home_url = f"{base_url}/remote.php/dav/calendars/{username}/"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import logging
|
|||||||
import logging.config
|
import logging.config
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
@@ -181,6 +182,10 @@ class Settings:
|
|||||||
nextcloud_username: Optional[str] = None
|
nextcloud_username: Optional[str] = None
|
||||||
nextcloud_password: Optional[str] = None
|
nextcloud_password: Optional[str] = None
|
||||||
|
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl: bool = True
|
||||||
|
nextcloud_ca_bundle: Optional[str] = None
|
||||||
|
|
||||||
# ADR-005: Token Audience Validation (required for OAuth mode)
|
# ADR-005: Token Audience Validation (required for OAuth mode)
|
||||||
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
|
||||||
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
|
||||||
@@ -252,9 +257,25 @@ class Settings:
|
|||||||
log_include_trace_context: bool = True
|
log_include_trace_context: bool = True
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Validate Qdrant configuration and set defaults."""
|
"""Validate configuration and set defaults."""
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Validate SSL/TLS configuration
|
||||||
|
if not self.nextcloud_verify_ssl:
|
||||||
|
logger.warning(
|
||||||
|
"NEXTCLOUD_VERIFY_SSL is disabled. "
|
||||||
|
"TLS certificate verification is turned off for all Nextcloud connections. "
|
||||||
|
"This is insecure and should only be used for development/testing."
|
||||||
|
)
|
||||||
|
if self.nextcloud_ca_bundle:
|
||||||
|
import os as _os
|
||||||
|
|
||||||
|
if not _os.path.isfile(self.nextcloud_ca_bundle):
|
||||||
|
raise ValueError(
|
||||||
|
f"NEXTCLOUD_CA_BUNDLE path does not exist: {self.nextcloud_ca_bundle}"
|
||||||
|
)
|
||||||
|
logger.info("Using custom CA bundle: %s", self.nextcloud_ca_bundle)
|
||||||
|
|
||||||
# Ensure mutual exclusivity
|
# Ensure mutual exclusivity
|
||||||
if self.qdrant_url and self.qdrant_location:
|
if self.qdrant_url and self.qdrant_location:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
@@ -504,6 +525,11 @@ def get_settings() -> Settings:
|
|||||||
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
|
||||||
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
|
||||||
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
# Nextcloud SSL/TLS settings
|
||||||
|
nextcloud_verify_ssl=(
|
||||||
|
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
|
||||||
|
),
|
||||||
|
nextcloud_ca_bundle=os.getenv("NEXTCLOUD_CA_BUNDLE"),
|
||||||
# ADR-005: Token Audience Validation
|
# ADR-005: Token Audience Validation
|
||||||
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
nextcloud_mcp_server_url=os.getenv("NEXTCLOUD_MCP_SERVER_URL"),
|
||||||
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
nextcloud_resource_uri=os.getenv("NEXTCLOUD_RESOURCE_URI"),
|
||||||
@@ -569,3 +595,20 @@ def get_settings() -> Settings:
|
|||||||
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
log_include_trace_context=os.getenv("LOG_INCLUDE_TRACE_CONTEXT", "true").lower()
|
||||||
== "true",
|
== "true",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_nextcloud_ssl_verify() -> bool | ssl.SSLContext:
|
||||||
|
"""Return the SSL verification setting for Nextcloud connections.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- False if NEXTCLOUD_VERIFY_SSL=false (disable verification)
|
||||||
|
- ssl.SSLContext if NEXTCLOUD_CA_BUNDLE is set (custom CA)
|
||||||
|
- True otherwise (default system CA verification)
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.nextcloud_verify_ssl:
|
||||||
|
return False
|
||||||
|
if settings.nextcloud_ca_bundle:
|
||||||
|
ctx = ssl.create_default_context(cafile=settings.nextcloud_ca_bundle)
|
||||||
|
return ctx
|
||||||
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Centralized HTTP client factory for Nextcloud connections.
|
||||||
|
|
||||||
|
All outbound connections to Nextcloud (API calls, OIDC endpoints) should use
|
||||||
|
these factories to ensure consistent SSL/TLS configuration from environment
|
||||||
|
variables (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .config import get_nextcloud_ssl_verify
|
||||||
|
|
||||||
|
|
||||||
|
def nextcloud_httpx_client(**kwargs: Any) -> httpx.AsyncClient:
|
||||||
|
"""Create an httpx.AsyncClient with Nextcloud SSL settings applied.
|
||||||
|
|
||||||
|
Reads NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE from the environment
|
||||||
|
via ``get_nextcloud_ssl_verify()``. Caller-supplied ``verify`` kwarg
|
||||||
|
takes precedence if explicitly provided.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: Forwarded to ``httpx.AsyncClient()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured ``httpx.AsyncClient``.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||||
|
return httpx.AsyncClient(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def nextcloud_httpx_transport(**kwargs: Any) -> httpx.AsyncHTTPTransport:
|
||||||
|
"""Create an httpx.AsyncHTTPTransport with Nextcloud SSL settings applied.
|
||||||
|
|
||||||
|
Used by ``NextcloudClient`` which wraps the transport in
|
||||||
|
``AsyncDisableCookieTransport``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
**kwargs: Forwarded to ``httpx.AsyncHTTPTransport()``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Configured ``httpx.AsyncHTTPTransport``.
|
||||||
|
"""
|
||||||
|
kwargs.setdefault("verify", get_nextcloud_ssl_verify())
|
||||||
|
return httpx.AsyncHTTPTransport(**kwargs)
|
||||||
@@ -11,7 +11,6 @@ import secrets
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
|
||||||
import jwt
|
import jwt
|
||||||
from mcp.server.auth.middleware.auth_context import get_access_token
|
from mcp.server.auth.middleware.auth_context import get_access_token
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
@@ -24,6 +23,8 @@ 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 ..http import nextcloud_httpx_client
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ async def extract_user_id_from_token(ctx: Context) -> str:
|
|||||||
"OIDC_DISCOVERY_URI",
|
"OIDC_DISCOVERY_URI",
|
||||||
"http://localhost:8080/.well-known/openid-configuration",
|
"http://localhost:8080/.well-known/openid-configuration",
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as http_client:
|
async with nextcloud_httpx_client() as http_client:
|
||||||
discovery_response = await http_client.get(oidc_discovery_uri)
|
discovery_response = await http_client.get(oidc_discovery_uri)
|
||||||
discovery_response.raise_for_status()
|
discovery_response.raise_for_status()
|
||||||
discovery = discovery_response.json()
|
discovery = discovery_response.json()
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.63.5"
|
version = "0.64.1"
|
||||||
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)",
|
||||||
|
|||||||
@@ -185,7 +185,11 @@ async def test_provision_app_password_success(temp_storage, mocker):
|
|||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client for Nextcloud validation
|
# Mock httpx client for Nextcloud validation
|
||||||
@@ -230,7 +234,11 @@ async def test_provision_app_password_nextcloud_validation_fails(mocker):
|
|||||||
"""Test that failed Nextcloud validation returns 401."""
|
"""Test that failed Nextcloud validation returns 401."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client to return 401
|
# Mock httpx client to return 401
|
||||||
@@ -349,7 +357,11 @@ async def test_delete_app_password_success(temp_storage, mocker):
|
|||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client for Nextcloud validation
|
# Mock httpx client for Nextcloud validation
|
||||||
@@ -393,7 +405,11 @@ async def test_delete_app_password_not_found(temp_storage, mocker):
|
|||||||
# Mock settings (imported locally in the function)
|
# Mock settings (imported locally in the function)
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client for Nextcloud validation
|
# Mock httpx client for Nextcloud validation
|
||||||
@@ -432,7 +448,11 @@ async def test_delete_app_password_invalid_credentials(mocker):
|
|||||||
"""Test that invalid credentials returns 401 for deletion."""
|
"""Test that invalid credentials returns 401 for deletion."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client to return 401
|
# Mock httpx client to return 401
|
||||||
@@ -502,7 +522,11 @@ async def test_provision_app_password_rate_limiting(mocker):
|
|||||||
"""Test that rate limiting blocks excessive provisioning attempts."""
|
"""Test that rate limiting blocks excessive provisioning attempts."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client to return 401 (failed validation)
|
# Mock httpx client to return 401 (failed validation)
|
||||||
@@ -561,7 +585,11 @@ async def test_rate_limiting_is_per_user(mocker):
|
|||||||
"""Test that rate limiting is applied per user, not globally."""
|
"""Test that rate limiting is applied per user, not globally."""
|
||||||
mocker.patch(
|
mocker.patch(
|
||||||
"nextcloud_mcp_server.config.get_settings",
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
return_value=MagicMock(nextcloud_host="http://localhost:8080"),
|
return_value=MagicMock(
|
||||||
|
nextcloud_host="http://localhost:8080",
|
||||||
|
nextcloud_verify_ssl=True,
|
||||||
|
nextcloud_ca_bundle=None,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock httpx client to return 401
|
# Mock httpx client to return 401
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Tests for SSL/TLS configuration (NEXTCLOUD_VERIFY_SSL, NEXTCLOUD_CA_BUNDLE)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import ssl
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import certifi
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from nextcloud_mcp_server.config import Settings, get_nextcloud_ssl_verify, get_settings
|
||||||
|
from nextcloud_mcp_server.http import nextcloud_httpx_client, nextcloud_httpx_transport
|
||||||
|
|
||||||
|
|
||||||
|
class TestSSLSettings:
|
||||||
|
"""Test SSL/TLS fields on Settings dataclass."""
|
||||||
|
|
||||||
|
def test_defaults(self):
|
||||||
|
"""verify_ssl defaults to True, ca_bundle defaults to None."""
|
||||||
|
settings = Settings()
|
||||||
|
assert settings.nextcloud_verify_ssl is True
|
||||||
|
assert settings.nextcloud_ca_bundle is None
|
||||||
|
|
||||||
|
def test_verify_ssl_false_logs_warning(self, caplog):
|
||||||
|
caplog.set_level(logging.WARNING, logger="nextcloud_mcp_server.config")
|
||||||
|
Settings(nextcloud_verify_ssl=False)
|
||||||
|
assert "NEXTCLOUD_VERIFY_SSL is disabled" in caplog.text
|
||||||
|
|
||||||
|
def test_ca_bundle_nonexistent_path_raises(self):
|
||||||
|
with pytest.raises(ValueError, match="does not exist"):
|
||||||
|
Settings(nextcloud_ca_bundle="/nonexistent/path/ca.pem")
|
||||||
|
|
||||||
|
def test_ca_bundle_existing_path_logs_info(self, caplog, tmp_path):
|
||||||
|
ca_file = tmp_path / "ca.pem"
|
||||||
|
ca_file.write_text(
|
||||||
|
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||||
|
)
|
||||||
|
caplog.set_level(logging.INFO, logger="nextcloud_mcp_server.config")
|
||||||
|
settings = Settings(nextcloud_ca_bundle=str(ca_file))
|
||||||
|
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||||
|
assert "Using custom CA bundle" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetNextcloudSSLVerify:
|
||||||
|
"""Test the get_nextcloud_ssl_verify() helper function."""
|
||||||
|
|
||||||
|
def test_default_returns_true(self):
|
||||||
|
env = {
|
||||||
|
"NEXTCLOUD_VERIFY_SSL": "true",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
# Clear any cached settings
|
||||||
|
result = get_nextcloud_ssl_verify()
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
def test_verify_false_returns_false(self):
|
||||||
|
env = {
|
||||||
|
"NEXTCLOUD_VERIFY_SSL": "false",
|
||||||
|
}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=Settings(nextcloud_verify_ssl=False),
|
||||||
|
):
|
||||||
|
result = get_nextcloud_ssl_verify()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
def test_ca_bundle_returns_ssl_context(self):
|
||||||
|
ca_bundle = certifi.where()
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||||
|
):
|
||||||
|
result = get_nextcloud_ssl_verify()
|
||||||
|
assert isinstance(result, ssl.SSLContext)
|
||||||
|
|
||||||
|
def test_ca_bundle_ssl_context_has_loaded_certs(self):
|
||||||
|
"""SSLContext created from CA bundle should have loaded certificates."""
|
||||||
|
ca_bundle = certifi.where()
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=Settings(nextcloud_ca_bundle=ca_bundle),
|
||||||
|
):
|
||||||
|
result = get_nextcloud_ssl_verify()
|
||||||
|
assert isinstance(result, ssl.SSLContext)
|
||||||
|
stats = result.cert_store_stats()
|
||||||
|
assert stats["x509_ca"] > 0
|
||||||
|
|
||||||
|
def test_verify_false_takes_precedence_over_ca_bundle(self, tmp_path):
|
||||||
|
"""When verify_ssl=False, ca_bundle is ignored (False wins)."""
|
||||||
|
ca_file = tmp_path / "ca.pem"
|
||||||
|
ca_file.write_text(
|
||||||
|
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||||
|
)
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.config.get_settings",
|
||||||
|
return_value=Settings(
|
||||||
|
nextcloud_verify_ssl=False,
|
||||||
|
nextcloud_ca_bundle=str(ca_file),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = get_nextcloud_ssl_verify()
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSettingsSSLEnvVars:
|
||||||
|
"""Test that get_settings() reads SSL env vars correctly."""
|
||||||
|
|
||||||
|
def test_verify_ssl_env_true(self):
|
||||||
|
env = {"NEXTCLOUD_VERIFY_SSL": "true"}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.nextcloud_verify_ssl is True
|
||||||
|
|
||||||
|
def test_verify_ssl_env_false(self):
|
||||||
|
env = {"NEXTCLOUD_VERIFY_SSL": "false"}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.nextcloud_verify_ssl is False
|
||||||
|
|
||||||
|
def test_verify_ssl_env_missing_defaults_true(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
# Remove NEXTCLOUD_VERIFY_SSL if it exists
|
||||||
|
os.environ.pop("NEXTCLOUD_VERIFY_SSL", None)
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.nextcloud_verify_ssl is True
|
||||||
|
|
||||||
|
def test_ca_bundle_env(self, tmp_path):
|
||||||
|
ca_file = tmp_path / "ca.pem"
|
||||||
|
ca_file.write_text(
|
||||||
|
"-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----\n"
|
||||||
|
)
|
||||||
|
env = {"NEXTCLOUD_CA_BUNDLE": str(ca_file)}
|
||||||
|
with patch.dict(os.environ, env, clear=False):
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.nextcloud_ca_bundle == str(ca_file)
|
||||||
|
|
||||||
|
|
||||||
|
class TestHTTPClientFactory:
|
||||||
|
"""Test that factory functions apply verify correctly."""
|
||||||
|
|
||||||
|
def test_client_applies_verify_true(self):
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||||
|
):
|
||||||
|
client = nextcloud_httpx_client()
|
||||||
|
# httpx stores verify as an SSLConfig; check the _transport
|
||||||
|
assert isinstance(client, httpx.AsyncClient)
|
||||||
|
|
||||||
|
def test_client_applies_verify_false(self):
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||||
|
):
|
||||||
|
client = nextcloud_httpx_client()
|
||||||
|
assert isinstance(client, httpx.AsyncClient)
|
||||||
|
|
||||||
|
def test_client_caller_override_takes_precedence(self):
|
||||||
|
"""Caller-supplied verify kwarg should not be overridden."""
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||||
|
):
|
||||||
|
client = nextcloud_httpx_client(verify=False)
|
||||||
|
assert isinstance(client, httpx.AsyncClient)
|
||||||
|
|
||||||
|
def test_transport_applies_verify(self):
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=False
|
||||||
|
):
|
||||||
|
transport = nextcloud_httpx_transport()
|
||||||
|
assert isinstance(transport, httpx.AsyncHTTPTransport)
|
||||||
|
|
||||||
|
def test_client_passes_extra_kwargs(self):
|
||||||
|
with patch(
|
||||||
|
"nextcloud_mcp_server.http.get_nextcloud_ssl_verify", return_value=True
|
||||||
|
):
|
||||||
|
client = nextcloud_httpx_client(timeout=5.0, follow_redirects=True)
|
||||||
|
assert isinstance(client, httpx.AsyncClient)
|
||||||
@@ -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.1"
|
||||||
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