Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 464ff2c8b2 | |||
| 0804ff8d17 | |||
| 4f7023a16e | |||
| 8f6656c546 | |||
| 741c58d9a3 | |||
| e7b79d0316 | |||
| 0e4cc8e56f | |||
| 16da7a9a76 | |||
| 520e515f2b | |||
| fd6ce7b294 | |||
| 8063059f5f | |||
| 20c5046b20 | |||
| 68126640d8 | |||
| af617e3869 | |||
| 04e5f7beca | |||
| 6ed1efab24 | |||
| cffa002364 | |||
| 951a7095b2 | |||
| ee31f33038 | |||
| 0fdbfae198 | |||
| 315f918d88 | |||
| 96a8491a4c | |||
| 0a311766f2 | |||
| d28c249f8d | |||
| ab6cac8799 | |||
| 7127b9953f | |||
| 49c9af3c76 | |||
| 823151f42e | |||
| 2bbd56e1cd | |||
| 8a36a120a7 | |||
| 9df8cc937d | |||
| 325dcdf654 | |||
| 945eb1eb4e | |||
| 088343d003 | |||
| 94d553985f | |||
| 982dbd18ca | |||
| 054fa38e3a | |||
| 3836534205 | |||
| f852a18b12 | |||
| 0450c5cc52 | |||
| f48fd0be60 | |||
| ee29194bc9 | |||
| fc32fa2852 | |||
| b7d6548741 | |||
| a9ffd49815 | |||
| 538f861414 | |||
| b784651f7f | |||
| 6f0baf5fca | |||
| 664254ed95 | |||
| b976494ca2 | |||
| 061f667e00 | |||
| 3319c35798 | |||
| 52c9293c37 | |||
| af6863a764 | |||
| 77181f7c6f | |||
| 61f3beac01 | |||
| 49aaf24363 | |||
| 4edd31ee28 |
@@ -15,7 +15,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Check out
|
- name: Check out
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
token: "${{ secrets.PERSONAL_ACCESS_TOKEN }}"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ jobs:
|
|||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
|
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||||
|
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
- 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@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3
|
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ jobs:
|
|||||||
linting:
|
linting:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
|
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||||
- name: Check format
|
- name: Check format
|
||||||
run: |
|
run: |
|
||||||
uv run --frozen ruff format --diff
|
uv run --frozen ruff format --diff
|
||||||
@@ -24,14 +24,14 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||||
|
|
||||||
- name: Run docker compose
|
- name: Run docker compose
|
||||||
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
|
uses: hoverkraft-tech/compose-action@40041ff1b97dbf152cd2361138c2b03fa29139df # v2.3.0
|
||||||
with:
|
with:
|
||||||
compose-file: "./docker-compose.yml"
|
compose-file: "./docker-compose.yml"
|
||||||
- name: Install the latest version of uv
|
- name: Install the latest version of uv
|
||||||
uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc # v6
|
uses: astral-sh/setup-uv@4959332f0f014c5280e7eac8b70c90cb574c9f9b # v6
|
||||||
|
|
||||||
- name: Wait for service to be ready
|
- name: Wait for service to be ready
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
|
## v0.7.2 (2025-08-30)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **client**: Use paging to fetch all notes
|
||||||
|
|
||||||
|
## v0.7.1 (2025-08-08)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- **client**: Strip cookies from responses to avoid falsely raising CSRF errors
|
||||||
|
|
||||||
## v0.7.0 (2025-08-03)
|
## v0.7.0 (2025-08-03)
|
||||||
|
|
||||||
### Feat
|
### 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.14-python3.11-alpine@sha256:7b1463148981d57ed2d9c2950f570fe5fdd88570970f9f56f6e0e5a8829eca95
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ php /var/www/html/occ app:enable calendar
|
|||||||
echo "Waiting for calendar app to initialize..."
|
echo "Waiting for calendar app to initialize..."
|
||||||
sleep 5
|
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
|
# Ensure maintenance mode is off before calendar operations
|
||||||
php /var/www/html/occ maintenance:mode --off
|
php /var/www/html/occ maintenance:mode --off
|
||||||
|
|
||||||
|
|||||||
+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: mariadb:lts@sha256:2bcbaec92bd9d4f6591bc8103d3a8e6d0512ee2235506e47a2e129d190444405
|
image: mariadb:lts@sha256:272084c2dec70619714df329c4ffcb336e3f8c723072c3f56f2e4015997bbf2c
|
||||||
restart: always
|
restart: always
|
||||||
command: --transaction-isolation=READ-COMMITTED
|
command: --transaction-isolation=READ-COMMITTED
|
||||||
volumes:
|
volumes:
|
||||||
@@ -17,11 +17,11 @@ 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: redis:alpine@sha256:25c0ae32c6c2301798579f5944af53729766a18eff5660bbef196fc2e6214a9c
|
image: redis:alpine@sha256:987c376c727652f99625c7d205a1cba3cb2c53b92b0b62aade2bd48ee1593232
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: nextcloud:31.0.7@sha256:81dc361f8f216d8acff20bd3dea2226fb6cea883c277505cbb2ddd6327c867fa
|
image: nextcloud:31.0.8@sha256:3eaddb0a9c56e6cf81ad258a5d05b78f747f6434b974f9a44e3f0dd91311b6ef
|
||||||
#user: www-data:www-data
|
#user: www-data:www-data
|
||||||
restart: always
|
restart: always
|
||||||
#post_start:
|
#post_start:
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
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 ..controllers.notes_search import NotesSearchController
|
||||||
from .calendar import CalendarClient
|
from .calendar import CalendarClient
|
||||||
@@ -13,19 +21,34 @@ from .webdav import WebDAVClient
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def log_request(request: Request):
|
async def log_request(request: Request):
|
||||||
logger.info(
|
logger.debug(
|
||||||
"Request event hook: %s %s - Waiting for content",
|
"Request event hook: %s %s - Waiting for content",
|
||||||
request.method,
|
request.method,
|
||||||
request.url,
|
request.url,
|
||||||
)
|
)
|
||||||
logger.info("Request body: %s", request.content)
|
logger.debug("Request body: %s", request.content)
|
||||||
logger.info("Headers: %s", request.headers)
|
logger.debug("Headers: %s", request.headers)
|
||||||
|
|
||||||
|
|
||||||
def log_response(response: Response):
|
async def log_response(response: Response):
|
||||||
response.read() # Explicitly read the stream before accessing .text
|
await response.aread()
|
||||||
logger.info("Response [%s] %s", response.status_code, response.text)
|
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:
|
class NextcloudClient:
|
||||||
@@ -36,7 +59,8 @@ class NextcloudClient:
|
|||||||
self._client = AsyncClient(
|
self._client = AsyncClient(
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
auth=auth,
|
auth=auth,
|
||||||
# event_hooks={"request": [log_request], "response": [log_response]},
|
transport=AsyncDisableCookieTransport(AsyncHTTPTransport()),
|
||||||
|
event_hooks={"request": [log_request], "response": [log_response]},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize app clients
|
# Initialize app clients
|
||||||
|
|||||||
@@ -3,11 +3,65 @@
|
|||||||
import logging
|
import logging
|
||||||
from abc import ABC
|
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__)
|
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):
|
class BaseNextcloudClient(ABC):
|
||||||
"""Base class for all Nextcloud app clients."""
|
"""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."""
|
"""Helper to get the base WebDAV path for the authenticated user."""
|
||||||
return f"/remote.php/dav/files/{self.username}"
|
return f"/remote.php/dav/files/{self.username}"
|
||||||
|
|
||||||
|
@retry_on_429
|
||||||
async def _make_request(self, method: str, url: str, **kwargs):
|
async def _make_request(self, method: str, url: str, **kwargs):
|
||||||
"""Common request wrapper with logging and error handling.
|
"""Common request wrapper with logging and error handling.
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,21 @@ class NotesClient(BaseNextcloudClient):
|
|||||||
|
|
||||||
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
async def get_all_notes(self) -> List[Dict[str, Any]]:
|
||||||
"""Get all notes."""
|
"""Get all notes."""
|
||||||
response = await self._make_request("GET", "/apps/notes/api/v1/notes")
|
notes = []
|
||||||
return response.json()
|
cursor = ""
|
||||||
|
|
||||||
|
while True:
|
||||||
|
response = await self._make_request(
|
||||||
|
"GET",
|
||||||
|
"/apps/notes/api/v1/notes",
|
||||||
|
params={"chunkSize": 50, "chunkCursor": cursor},
|
||||||
|
)
|
||||||
|
notes.extend(response.json())
|
||||||
|
if "X-Notes-Chunk-Cursor" not in response.headers:
|
||||||
|
break
|
||||||
|
cursor = response.headers["X-Notes-Chunk-Cursor"]
|
||||||
|
|
||||||
|
return notes
|
||||||
|
|
||||||
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
async def get_note(self, note_id: int) -> Dict[str, Any]:
|
||||||
"""Get a specific note by ID."""
|
"""Get a specific note by ID."""
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
description = ""
|
description = ""
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
{name = "Chris Coutinho",email = "chris@coutinho.io"}
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ from nextcloud_mcp_server.client import NextcloudClient
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="session")
|
||||||
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
async def nc_client() -> AsyncGenerator[NextcloudClient, Any]:
|
||||||
"""
|
"""
|
||||||
Fixture to create a NextcloudClient instance for integration tests.
|
Fixture to create a NextcloudClient instance for integration tests.
|
||||||
|
|||||||
@@ -505,7 +505,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nextcloud-mcp-server"
|
name = "nextcloud-mcp-server"
|
||||||
version = "0.7.0"
|
version = "0.7.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|||||||
Reference in New Issue
Block a user