Compare commits

...

36 Commits

Author SHA1 Message Date
github-actions[bot] b1bd025aac bump: version 0.57.54 → 0.57.55 2026-02-17 13:45:28 +00:00
Chris Coutinho 8a1c604d78 Merge pull request #499 from cbcoutinho/renovate/quay.io-keycloak-keycloak-26.x
chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.3
2026-02-17 14:45:10 +01:00
github-actions[bot] 3616dee54c bump: version 0.57.53 → 0.57.54 2026-02-17 04:38:16 +00:00
Chris Coutinho dbb36a7b63 Merge pull request #550 from cbcoutinho/renovate/ghcr.io-astral-sh-uv-0.x
chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.3
2026-02-17 05:38:01 +01:00
github-actions[bot] f1797b2f8e bump: version 0.57.52 → 0.57.53 2026-02-17 04:37:51 +00:00
Chris Coutinho 1d5d4f86d7 Merge pull request #429 from cbcoutinho/renovate/actions-checkout-6.x
chore(deps): update actions/checkout action to v6.0.2
2026-02-17 05:37:36 +01:00
github-actions[bot] 44030805f1 bump: version 0.57.51 → 0.57.52 2026-02-17 04:37:17 +00:00
Chris Coutinho afd7e69f76 Merge pull request #561 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.53
2026-02-17 05:37:01 +01:00
renovate-bot-cbcoutinho[bot] 31be72ae24 chore(deps): update anthropics/claude-code-action action to v1.0.53 2026-02-16 23:10:46 +00:00
github-actions[bot] 6bd05a81bf bump: version 0.57.50 → 0.57.51 2026-02-16 14:49:23 +00:00
github-actions[bot] a4e3f0b354 bump: version 0.63.5 → 0.64.0 2026-02-16 14:49:23 +00:00
Chris Coutinho 0f23964752 Merge pull request #562 from cbcoutinho/feat/self-signed-ssl-support
feat: add self-signed SSL certificate support
2026-02-16 15:49:00 +01:00
Chris Coutinho 66ccacdee1 fix: add type: ignore for caldav ssl_verify_cert parameter
caldav types declare ssl_verify_cert as Union[bool, str] but the value
is passed through to httpx which accepts ssl.SSLContext. Add type: ignore
annotation to satisfy the ty type checker in CI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:34:05 +01:00
Chris Coutinho 1a4486a388 fix: convert CA bundle path to ssl.SSLContext to avoid httpx deprecation warning
httpx emits a DeprecationWarning when verify=<str> is passed, recommending
ssl.SSLContext instead. This affected both our httpx client factories and
the caldav library passthrough.

Changed get_nextcloud_ssl_verify() to return bool | ssl.SSLContext instead
of bool | str by constructing an SSLContext when NEXTCLOUD_CA_BUNDLE is set.
All downstream consumers (httpx, caldav) natively accept ssl.SSLContext.

Also fixed app password endpoint tests that used overly broad MagicMock
(auto-generated truthy nextcloud_ca_bundle attribute).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:27:22 +01:00
github-actions[bot] 91d06acfb4 bump: version 0.57.49 → 0.57.50 2026-02-16 11:38:36 +00:00
Chris Coutinho 90874ca7cd Merge pull request #563 from cbcoutinho/renovate/ollama-1.x
chore(deps): update helm release ollama to v1.43.0
2026-02-16 12:37:47 +01:00
renovate-bot-cbcoutinho[bot] da8fed3382 chore(deps): update helm release ollama to v1.43.0 2026-02-16 11:16:48 +00:00
renovate-bot-cbcoutinho[bot] 8963e65f1b chore(deps): update ghcr.io/astral-sh/uv docker tag to v0.10.3 2026-02-16 11:16:20 +00:00
renovate-bot-cbcoutinho[bot] 75c3868e74 chore(deps): update actions/checkout action to v6.0.2 2026-02-16 11:16:12 +00:00
Chris Coutinho 1707b2e6e1 feat: add self-signed SSL certificate support for Nextcloud connections
Add NEXTCLOUD_VERIFY_SSL and NEXTCLOUD_CA_BUNDLE env vars to configure
TLS certificate verification for all outbound Nextcloud connections.
Centralizes SSL config via a new HTTP client factory (http.py) used by
all 27 Nextcloud-bound call sites, including API clients, OIDC endpoints,
OAuth flows, and health checks.

Closes #560

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:21:21 +01:00
github-actions[bot] df3cce4370 bump: version 0.57.48 → 0.57.49 2026-02-16 07:20:08 +00:00
github-actions[bot] 1c5e21843e bump: version 0.63.4 → 0.63.5 2026-02-16 07:20:07 +00:00
Chris Coutinho 520ef113ba Merge pull request #549 from cbcoutinho/extract-astrolabe
Extract Astrolabe to separate repository
2026-02-16 08:19:48 +01:00
github-actions[bot] 6da69b0336 bump: version 0.57.47 → 0.57.48 2026-02-15 19:37:43 +00:00
Chris Coutinho 427e501691 Merge pull request #556 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.51
2026-02-15 20:37:28 +01:00
github-actions[bot] 9c275d1a3f bump: version 0.57.46 → 0.57.47 2026-02-15 19:17:18 +00:00
Chris Coutinho af43630ca7 Merge pull request #558 from cbcoutinho/renovate/docker.io-library-nextcloud-32.x
chore(deps): update docker.io/library/nextcloud docker tag to v32.0.6
2026-02-15 20:17:02 +01:00
renovate-bot-cbcoutinho[bot] c5eec64716 chore(deps): update docker.io/library/nextcloud docker tag to v32.0.6 2026-02-15 11:10:51 +00:00
renovate-bot-cbcoutinho[bot] 3948f6a019 chore(deps): update anthropics/claude-code-action action to v1.0.51 2026-02-14 11:16:18 +00:00
github-actions[bot] 4712235390 bump: version 0.57.45 → 0.57.46 2026-02-12 22:01:48 +00:00
Chris Coutinho d0f18b36e8 Merge pull request #541 from cbcoutinho/renovate/anthropics-claude-code-action-1.x
chore(deps): update anthropics/claude-code-action action to v1.0.49
2026-02-12 23:01:30 +01:00
github-actions[bot] aca0d236b4 bump: version 0.57.44 → 0.57.45 2026-02-12 22:01:04 +00:00
Chris Coutinho 7ab0dcd3d8 Merge pull request #553 from cbcoutinho/renovate/downloads.unstructured.io-unstructured-io-unstructured-api-latest
chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 3b9280e
2026-02-12 23:00:48 +01:00
renovate-bot-cbcoutinho[bot] eafef986f2 chore(deps): update anthropics/claude-code-action action to v1.0.49 2026-02-12 11:14:09 +00:00
renovate-bot-cbcoutinho[bot] 8126beb16e chore(deps): update downloads.unstructured.io/unstructured-io/unstructured-api:latest docker digest to 3b9280e 2026-02-12 11:14:03 +00:00
renovate-bot-cbcoutinho[bot] 291a13c064 chore(deps): update quay.io/keycloak/keycloak docker tag to v26.5.3 2026-02-10 11:09:44 +00:00
36 changed files with 504 additions and 64 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
- name: Run Claude Code Review - name: Run Claude Code Review
id: claude-review id: claude-review
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 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"
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
- name: Run Claude Code - name: Run Claude Code
id: claude id: claude
uses: anthropics/claude-code-action@b113f49a56229d8276e2bf05743ad6900121239c # v1.0.45 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 }}
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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'
+18
View File
@@ -5,6 +5,24 @@ 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.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)
### Refactor
- remove stale astrolabe references from commitizen config
- extract Astrolabe to separate repository
## v0.63.4 (2026-02-08) ## v0.63.4 (2026-02-08)
### Fix ### Fix
+1 -1
View File
@@ -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.3@sha256:7a88d4c4e6f44200575000638453a5a381db0ae31ad5c3a51b14f8687c9d93a3 /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
View File
@@ -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.3@sha256:7a88d4c4e6f44200575000638453a5a381db0ae31ad5c3a51b14f8687c9d93a3 /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
View File
@@ -1,6 +1,6 @@
[tool.commitizen] [tool.commitizen]
name = "cz_conventional_commits" name = "cz_conventional_commits"
version = "0.57.44" version = "0.57.55"
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
+36
View File
@@ -14,6 +14,42 @@ 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.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.47 (2026-02-15)
## nextcloud-mcp-server-0.57.46 (2026-02-12)
## nextcloud-mcp-server-0.57.45 (2026-02-12)
## nextcloud-mcp-server-0.57.44 (2026-02-11) ## nextcloud-mcp-server-0.57.44 (2026-02-11)
## nextcloud-mcp-server-0.57.43 (2026-02-11) ## nextcloud-mcp-server-0.57.43 (2026-02-11)
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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.44 version: 0.57.55
appVersion: "0.63.4" appVersion: "0.64.0"
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
View File
@@ -23,7 +23,7 @@ services:
restart: always restart: always
app: app:
image: docker.io/library/nextcloud:32.0.5@sha256:4b66e9bd8cb2c8af5457c1e2606c9937af2fcccbe4f6338956bc5990caec8968 image: docker.io/library/nextcloud:32.0.6@sha256:0e1084cc59df77bec7d6bb29d9ac6939da8372512237a9c51f74ff0a970524f2
restart: always restart: always
ports: ports:
- 127.0.0.1:8080:80 - 127.0.0.1:8080:80
@@ -60,7 +60,7 @@ services:
- ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro - ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro
unstructured: unstructured:
image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:dcc82dcdd57241a7db2d78fd73c595d399ef4c1265fc8028fbe624a5eb51ecc3 image: downloads.unstructured.io/unstructured-io/unstructured-api:latest@sha256:3b9280eb9aa53d76a8f4a2465400ae747774d4bfd71dd73d603353b0b55c435d
restart: always restart: always
ports: ports:
- 127.0.0.1:8002:8000 - 127.0.0.1:8002:8000
@@ -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"
+52
View File
@@ -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
View File
@@ -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
+8 -2
View File
@@ -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,
+6 -5
View File
@@ -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,
+4 -3
View File
@@ -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
+3 -1
View File
@@ -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:
+6 -5
View File
@@ -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,
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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
+4 -3
View File
@@ -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}"},
+4 -2
View File
@@ -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,
+2 -2
View File
@@ -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),
) )
+3
View File
@@ -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}/"
+44 -1
View File
@@ -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
+45
View File
@@ -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)
+3 -2
View File
@@ -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()
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.63.4" version = "0.64.0"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data" description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [ authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"} {name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -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
+178
View File
@@ -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)
Generated
+1 -1
View File
@@ -1988,7 +1988,7 @@ wheels = [
[[package]] [[package]]
name = "nextcloud-mcp-server" name = "nextcloud-mcp-server"
version = "0.63.4" version = "0.64.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiosqlite" }, { name = "aiosqlite" },