Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 982dbd18ca | |||
| 054fa38e3a | |||
| 3836534205 | |||
| f852a18b12 | |||
| 0450c5cc52 | |||
| f48fd0be60 | |||
| ee29194bc9 | |||
| fc32fa2852 | |||
| b7d6548741 | |||
| a9ffd49815 | |||
| 538f861414 | |||
| b784651f7f | |||
| 6f0baf5fca | |||
| 664254ed95 | |||
| b976494ca2 | |||
| 061f667e00 | |||
| 3319c35798 | |||
| 52c9293c37 | |||
| af6863a764 | |||
| 77181f7c6f | |||
| 61f3beac01 | |||
| 49aaf24363 | |||
| 4edd31ee28 |
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
## v0.7.1 (2025-08-08)
|
||||
|
||||
### Fix
|
||||
|
||||
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||
|
||||
## v0.7.0 (2025-08-03)
|
||||
|
||||
### Feat
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM ghcr.io/astral-sh/uv:0.8.4-python3.11-alpine@sha256:f2c5b953b713f455bcac4429303bb21d7d2547d56a64e1a7b2517cc9f0563f0f
|
||||
FROM ghcr.io/astral-sh/uv:0.8.6-python3.11-alpine@sha256:15e7f7ee738f1b8a0e1ed95da7cf821c58b77d3d15275bf8f4605fbbf36679f4
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ php /var/www/html/occ app:enable calendar
|
||||
echo "Waiting for calendar app to initialize..."
|
||||
sleep 5
|
||||
|
||||
# Increase limits on calendar creation for integration tests (100 in 60s)
|
||||
php occ config:app:set dav rateLimitCalendarCreation --type=integer --value=100
|
||||
php occ config:app:set dav rateLimitPeriodCalendarCreation --type=integer --value=60
|
||||
|
||||
# Ensure maintenance mode is off before calendar operations
|
||||
php /var/www/html/occ maintenance:mode --off
|
||||
|
||||
|
||||
+2
-2
@@ -17,11 +17,11 @@ services:
|
||||
# Note: Redis is an external service. You can find more information about the configuration here:
|
||||
# https://hub.docker.com/_/redis
|
||||
redis:
|
||||
image: redis:alpine@sha256:25c0ae32c6c2301798579f5944af53729766a18eff5660bbef196fc2e6214a9c
|
||||
image: redis:alpine@sha256:7521abdff715d396aa482183942f3fe643344287c29ccb66eee16ac08a92190f
|
||||
restart: always
|
||||
|
||||
app:
|
||||
image: nextcloud:31.0.7@sha256:81dc361f8f216d8acff20bd3dea2226fb6cea883c277505cbb2ddd6327c867fa
|
||||
image: nextcloud:31.0.7@sha256:a834f43b159890322d471862a3d8ab48602d4a619499aa7bc94b662cf4463e66
|
||||
#user: www-data:www-data
|
||||
restart: always
|
||||
#post_start:
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import logging
|
||||
import os
|
||||
|
||||
from httpx import AsyncClient, Auth, BasicAuth, Request, Response
|
||||
from httpx import (
|
||||
AsyncClient,
|
||||
Auth,
|
||||
BasicAuth,
|
||||
Request,
|
||||
Response,
|
||||
AsyncBaseTransport,
|
||||
AsyncHTTPTransport,
|
||||
)
|
||||
|
||||
from ..controllers.notes_search import NotesSearchController
|
||||
from .calendar import CalendarClient
|
||||
@@ -13,19 +21,34 @@ from .webdav import WebDAVClient
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def log_request(request: Request):
|
||||
logger.info(
|
||||
async def log_request(request: Request):
|
||||
logger.debug(
|
||||
"Request event hook: %s %s - Waiting for content",
|
||||
request.method,
|
||||
request.url,
|
||||
)
|
||||
logger.info("Request body: %s", request.content)
|
||||
logger.info("Headers: %s", request.headers)
|
||||
logger.debug("Request body: %s", request.content)
|
||||
logger.debug("Headers: %s", request.headers)
|
||||
|
||||
|
||||
def log_response(response: Response):
|
||||
response.read() # Explicitly read the stream before accessing .text
|
||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
||||
async def log_response(response: Response):
|
||||
await response.aread()
|
||||
logger.debug("Response [%s] %s", response.status_code, response.text)
|
||||
|
||||
|
||||
class AsyncDisableCookieTransport(AsyncBaseTransport):
|
||||
"""This Transport disable cookies from accumulating in the httpx AsyncClient
|
||||
|
||||
Thanks to: https://github.com/encode/httpx/issues/2992#issuecomment-2133258994
|
||||
"""
|
||||
|
||||
def __init__(self, transport: AsyncBaseTransport):
|
||||
self.transport = transport
|
||||
|
||||
async def handle_async_request(self, request: Request) -> Response:
|
||||
response = await self.transport.handle_async_request(request)
|
||||
response.headers.pop("set-cookie", None)
|
||||
return response
|
||||
|
||||
|
||||
class NextcloudClient:
|
||||
@@ -36,7 +59,8 @@ class NextcloudClient:
|
||||
self._client = AsyncClient(
|
||||
base_url=base_url,
|
||||
auth=auth,
|
||||
# event_hooks={"request": [log_request], "response": [log_response]},
|
||||
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||
event_hooks={"request": [log_request], "response": [log_response]},
|
||||
)
|
||||
|
||||
# Initialize app clients
|
||||
|
||||
@@ -3,11 +3,65 @@
|
||||
import logging
|
||||
from abc import ABC
|
||||
|
||||
from httpx import AsyncClient
|
||||
from functools import wraps
|
||||
import time
|
||||
from httpx import HTTPStatusError, codes, RequestError, AsyncClient
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def retry_on_429(func):
|
||||
"""This decorator handles the 429 response from REST APIs
|
||||
|
||||
The `func` is assumed to be a method that is similar to `httpx.Client.get`,
|
||||
and returns an `httpx.Response` object. In the case of `Too Many Requests` HTTP
|
||||
response, the function will wait for a couple of seconds and retry the request.
|
||||
"""
|
||||
|
||||
MAX_RETRIES = 5
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
retries = 0
|
||||
|
||||
while retries < MAX_RETRIES:
|
||||
try:
|
||||
# Make GET API call
|
||||
retries += 1
|
||||
response = await func(*args, **kwargs)
|
||||
break
|
||||
|
||||
except HTTPStatusError as e:
|
||||
# If we get a '429 Client Error: Too Many Requests'
|
||||
# error we wait a couple of seconds and do a retry
|
||||
if e.response.status_code == codes.TOO_MANY_REQUESTS:
|
||||
logger.warning(
|
||||
f"429 Client Error: Too Many Requests, Number of attempts: {retries}"
|
||||
)
|
||||
time.sleep(5)
|
||||
else:
|
||||
logger.warning(
|
||||
f"HTTPStatusError {e.response.status_code}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
except RequestError as e:
|
||||
logger.warning(
|
||||
f"RequestError {e.request.url}: {e}, Number of attempts: {retries}"
|
||||
)
|
||||
raise
|
||||
|
||||
# If for loop ends without break statement
|
||||
else:
|
||||
logger.warning("All API call retries failed")
|
||||
raise RuntimeError(
|
||||
f"Maximum number of retries ({MAX_RETRIES}) exceeded without success"
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
class BaseNextcloudClient(ABC):
|
||||
"""Base class for all Nextcloud app clients."""
|
||||
|
||||
@@ -25,6 +79,7 @@ class BaseNextcloudClient(ABC):
|
||||
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||
return f"/remote.php/dav/files/{self.username}"
|
||||
|
||||
@retry_on_429
|
||||
async def _make_request(self, method: str, url: str, **kwargs):
|
||||
"""Common request wrapper with logging and error handling.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "nextcloud-mcp-server"
|
||||
version = "0.7.0"
|
||||
version = "0.7.1"
|
||||
description = ""
|
||||
authors = [
|
||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ from nextcloud_mcp_server.client import NextcloudClient
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@pytest.fixture(scope="session")
|
||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||
"""
|
||||
Fixture to create a NextcloudClient instance for integration tests.
|
||||
|
||||
Reference in New Issue
Block a user