From 604a2065cbc27078a151856192601ef0d5900016 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 03:40:40 +0200 Subject: [PATCH 1/8] chore: trigger --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index db53b38..a5af39b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,6 @@ # Nextcloud MCP Server + [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) **Enable AI assistants to interact with your Nextcloud instance.** From 0fd32ecd34c465193017b81eb1c863440667a1f7 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 03:58:36 +0200 Subject: [PATCH 2/8] test: Fix test networking --- .github/workflows/test.yml | 2 +- docker-compose.yml | 2 ++ tests/client/cookbook/test_cookbook_api.py | 10 +--------- tests/server/test_cookbook_mcp.py | 7 +------ 4 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 526620d..ec11afd 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,4 +62,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run pytest -v --browser firefox + uv run pytest -v --browser firefox -k 'test_mcp_cookbook_import_recipe_from_url or test_cookbook_import_recipe_from_url' diff --git a/docker-compose.yml b/docker-compose.yml index c36b8cb..b85a8df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,8 @@ services: - MYSQL_DATABASE=nextcloud - MYSQL_USER=nextcloud - MYSQL_HOST=db + extra_hosts: + - "host.docker.internal:host-gateway" mcp: build: . diff --git a/tests/client/cookbook/test_cookbook_api.py b/tests/client/cookbook/test_cookbook_api.py index 65c1ca0..5151374 100644 --- a/tests/client/cookbook/test_cookbook_api.py +++ b/tests/client/cookbook/test_cookbook_api.py @@ -161,16 +161,8 @@ async def test_cookbook_import_recipe_from_url( This is the key feature test - importing recipes from URLs using schema.org metadata. Uses a local test server to provide reliable, controlled test data. """ - # Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it - # The test_recipe_server runs on the host, but Nextcloud runs in Docker - # On Linux, 172.17.0.1 is the default Docker bridge gateway - # On Mac/Windows, try host.docker.internal first - import platform - if platform.system() == "Linux": - docker_host = "172.17.0.1" - else: - docker_host = "host.docker.internal" + docker_host = "host.docker.internal" docker_accessible_url = test_recipe_server.replace("localhost", docker_host) test_url = f"{docker_accessible_url}/black-pepper-tofu" diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py index aa11428..286659e 100644 --- a/tests/server/test_cookbook_mcp.py +++ b/tests/server/test_cookbook_mcp.py @@ -1,7 +1,6 @@ import asyncio import json import logging -import platform import uuid import pytest @@ -216,11 +215,7 @@ async def test_mcp_cookbook_import_recipe_from_url( This is the key feature test - importing recipes from URLs using schema.org metadata. Uses a local test server to provide reliable, controlled test data. """ - # Replace localhost with Docker bridge gateway IP so the Nextcloud container can reach it - if platform.system() == "Linux": - docker_host = "172.17.0.1" - else: - docker_host = "host.docker.internal" + docker_host = "host.docker.internal" docker_accessible_url = test_recipe_server.replace("localhost", docker_host) test_url = f"{docker_accessible_url}/black-pepper-tofu" From 2999d4b65e3b42adc35b1a713ea0d50f7f6df642 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:17:41 +0200 Subject: [PATCH 3/8] fix: Handle RequestError in mcp tools --- nextcloud_mcp_server/server/cookbook.py | 9 ++++- nextcloud_mcp_server/server/notes.py | 49 ++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 8f534ce..8561b01 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -1,6 +1,6 @@ import logging -from httpx import HTTPStatusError +from httpx import HTTPStatusError, RequestError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData @@ -83,6 +83,13 @@ def configure_cookbook_tools(mcp: FastMCP): recipe=recipe, recipe_id=recipe.id or "unknown", ) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, + message=f"Network error importing recipe from {url}: {str(e)}", + ) + ) except HTTPStatusError as e: if e.response.status_code == 400: raise McpError( diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index a241633..631ea7b 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -1,6 +1,6 @@ import logging -from httpx import HTTPStatusError +from httpx import HTTPStatusError, RequestError from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData @@ -61,6 +61,13 @@ def configure_notes_tools(mcp: FastMCP): try: note_data = await client.notes.get_note(note_id) return Note(**note_data) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, + message=f"Network error retrieving note {note_id}: {str(e)}", + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) @@ -92,6 +99,10 @@ def configure_notes_tools(mcp: FastMCP): return CreateNoteResponse( id=note.id, title=note.title, category=note.category, etag=note.etag ) + except RequestError as e: + raise McpError( + ErrorData(code=-1, message=f"Network error creating note: {str(e)}") + ) except HTTPStatusError as e: if e.response.status_code == 403: raise McpError( @@ -146,6 +157,12 @@ def configure_notes_tools(mcp: FastMCP): return UpdateNoteResponse( id=note.id, title=note.title, category=note.category, etag=note.etag ) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, message=f"Network error updating note {note_id}: {str(e)}" + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) @@ -192,6 +209,13 @@ def configure_notes_tools(mcp: FastMCP): return AppendContentResponse( id=note.id, title=note.title, category=note.category, etag=note.etag ) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, + message=f"Network error appending to note {note_id}: {str(e)}", + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) @@ -238,6 +262,10 @@ def configure_notes_tools(mcp: FastMCP): return SearchNotesResponse( results=results, query=query, total_found=len(results) ) + except RequestError as e: + raise McpError( + ErrorData(code=-1, message=f"Network error searching notes: {str(e)}") + ) except HTTPStatusError as e: if e.response.status_code == 403: raise McpError( @@ -265,6 +293,12 @@ def configure_notes_tools(mcp: FastMCP): try: note_data = await client.notes.get_note(note_id) return Note(**note_data) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, message=f"Network error getting note {note_id}: {str(e)}" + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) @@ -295,6 +329,13 @@ def configure_notes_tools(mcp: FastMCP): "mimeType": mime_type, "data": content, } + except RequestError as e: + raise McpError( + ErrorData( + code=-1, + message=f"Network error getting attachment {attachment_filename} for note {note_id}: {str(e)}", + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError( @@ -330,6 +371,12 @@ def configure_notes_tools(mcp: FastMCP): message=f"Note {note_id} deleted successfully", deleted_id=note_id, ) + except RequestError as e: + raise McpError( + ErrorData( + code=-1, message=f"Network error deleting note {note_id}: {str(e)}" + ) + ) except HTTPStatusError as e: if e.response.status_code == 404: raise McpError(ErrorData(code=-1, message=f"Note {note_id} not found")) From 27519d0f6272ecfc2f596da0b75b6cb5814b4a2d Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:30:03 +0200 Subject: [PATCH 4/8] test: Replace http server for recipes with nginx container --- docker-compose.yml | 9 ++- tests/client/cookbook/test_cookbook_api.py | 16 ++--- tests/conftest.py | 74 ---------------------- tests/fixtures/nginx.conf | 24 +++++++ tests/server/test_cookbook_mcp.py | 13 ++-- 5 files changed, 41 insertions(+), 95 deletions(-) create mode 100644 tests/fixtures/nginx.conf diff --git a/docker-compose.yml b/docker-compose.yml index b85a8df..8bdbf3c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,8 +39,13 @@ services: - MYSQL_DATABASE=nextcloud - MYSQL_USER=nextcloud - MYSQL_HOST=db - extra_hosts: - - "host.docker.internal:host-gateway" + + recipes: + image: docker.io/library/nginx:alpine + restart: always + volumes: + - ./tests/fixtures/test_recipe.html:/usr/share/nginx/html/test_recipe.html:ro + - ./tests/fixtures/nginx.conf:/etc/nginx/nginx.conf:ro mcp: build: . diff --git a/tests/client/cookbook/test_cookbook_api.py b/tests/client/cookbook/test_cookbook_api.py index 5151374..c94df2e 100644 --- a/tests/client/cookbook/test_cookbook_api.py +++ b/tests/client/cookbook/test_cookbook_api.py @@ -153,21 +153,17 @@ async def test_cookbook_delete_nonexistent_recipe(nc_client: NextcloudClient): logger.info(f"Delete correctly failed with {e.response.status_code}") -async def test_cookbook_import_recipe_from_url( - nc_client: NextcloudClient, test_recipe_server: str -): +async def test_cookbook_import_recipe_from_url(nc_client: NextcloudClient): """Test importing a recipe from a URL. This is the key feature test - importing recipes from URLs using schema.org metadata. - Uses a local test server to provide reliable, controlled test data. + Uses an nginx container to serve reliable, controlled test data. """ - docker_host = "host.docker.internal" + # Use the nginx container hostname within the Docker network + test_url = "http://recipes/black-pepper-tofu" - docker_accessible_url = test_recipe_server.replace("localhost", docker_host) - test_url = f"{docker_accessible_url}/black-pepper-tofu" - - logger.info(f"Importing recipe from local test URL (Docker-accessible): {test_url}") + logger.info(f"Importing recipe from nginx container: {test_url}") try: imported_recipe = await nc_client.cookbook.import_recipe(test_url) @@ -205,7 +201,7 @@ async def test_cookbook_import_recipe_from_url( elif e.response.status_code == 400: # URL couldn't be imported logger.error( - f"Failed to import recipe from local test URL: {test_url}. " + f"Failed to import recipe from nginx container: {test_url}. " f"Status: {e.response.status_code}, Response: {e.response.text}" ) raise diff --git a/tests/conftest.py b/tests/conftest.py index 66fc606..3e898cc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1319,77 +1319,3 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group): logger.debug(f"Added user {user_config['userid']} to group {groupid}") yield (user_config, groupid) - - -@pytest.fixture(scope="session") -def test_recipe_server(): - """ - Fixture to create a local HTTP server serving test recipe HTML pages. - - This serves static HTML files with schema.org Recipe JSON-LD data for testing - recipe import functionality without relying on external websites. - - Yields the server URL (e.g., "http://localhost:8082") - """ - import threading - from http.server import BaseHTTPRequestHandler, HTTPServer - from pathlib import Path - - httpd = None - server_thread = None - - # Get the path to the fixtures directory - fixtures_dir = Path(__file__).parent / "fixtures" - - class RecipeServerHandler(BaseHTTPRequestHandler): - def log_message(self, format, *args): - # Suppress default HTTP logging - pass - - def do_GET(self): - # Map URL paths to fixture files - if self.path == "/black-pepper-tofu": - file_path = fixtures_dir / "test_recipe.html" - else: - # 404 for unknown paths - self.send_response(404) - self.end_headers() - return - - if file_path.exists(): - with open(file_path, "rb") as f: - content = f.read() - - self.send_response(200) - self.send_header("Content-type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(content))) - self.end_headers() - self.wfile.write(content) - else: - self.send_response(404) - self.end_headers() - - try: - # Start the HTTP server on all interfaces (0.0.0.0) so Docker can reach it - httpd = HTTPServer(("0.0.0.0", 8082), RecipeServerHandler) - server_thread = threading.Thread(target=httpd.serve_forever) - server_thread.daemon = True - server_thread.start() - logger.info( - "Test recipe server started on http://0.0.0.0:8082 (accessible from Docker)" - ) - - # Yield the server URL (use localhost for test code, will be replaced with Docker-accessible IP) - yield "http://localhost:8082" - - finally: - # Clean up the server - if httpd: - logger.info("Shutting down test recipe server...") - shutdown_thread = threading.Thread(target=httpd.shutdown) - shutdown_thread.start() - shutdown_thread.join(timeout=2) - httpd.server_close() - logger.info("Test recipe server shut down successfully") - if server_thread: - server_thread.join(timeout=1) diff --git a/tests/fixtures/nginx.conf b/tests/fixtures/nginx.conf new file mode 100644 index 0000000..14298e8 --- /dev/null +++ b/tests/fixtures/nginx.conf @@ -0,0 +1,24 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type text/html; + + server { + listen 80; + server_name _; + + location / { + root /usr/share/nginx/html; + try_files $uri $uri.html =404; + } + + # Serve test_recipe.html at /black-pepper-tofu + location = /black-pepper-tofu { + root /usr/share/nginx/html; + try_files /test_recipe.html =404; + } + } +} diff --git a/tests/server/test_cookbook_mcp.py b/tests/server/test_cookbook_mcp.py index 286659e..e4c90a3 100644 --- a/tests/server/test_cookbook_mcp.py +++ b/tests/server/test_cookbook_mcp.py @@ -208,25 +208,20 @@ async def test_mcp_cookbook_delete_recipe( async def test_mcp_cookbook_import_recipe_from_url( nc_mcp_client: ClientSession, nc_client: NextcloudClient, - test_recipe_server: str, ): """Test importing a recipe from a URL via MCP tools. This is the key feature test - importing recipes from URLs using schema.org metadata. - Uses a local test server to provide reliable, controlled test data. + Uses an nginx container to serve reliable, controlled test data. """ - docker_host = "host.docker.internal" - - docker_accessible_url = test_recipe_server.replace("localhost", docker_host) - test_url = f"{docker_accessible_url}/black-pepper-tofu" + # Use the nginx container hostname within the Docker network + test_url = "http://recipes/black-pepper-tofu" created_recipe_id = None try: # 1. Import recipe via MCP - logger.info( - f"Importing recipe from local test URL via MCP (Docker-accessible): {test_url}" - ) + logger.info(f"Importing recipe from nginx container via MCP: {test_url}") import_result = await nc_mcp_client.call_tool( "nc_cookbook_import_recipe", {"url": test_url} ) From dbcf9d93ca17a44cc6cf3133dd7a0beacdc9aa5e Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:37:25 +0200 Subject: [PATCH 5/8] chore: Improve RequestError message details Show exception type and cause when str(e) is empty for better debugging --- nextcloud_mcp_server/server/cookbook.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index 8561b01..fdbcc43 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -84,10 +84,15 @@ def configure_cookbook_tools(mcp: FastMCP): recipe_id=recipe.id or "unknown", ) except RequestError as e: + # RequestError can have empty str() - get details from exception attributes + error_detail = ( + str(e) + or f"{type(e).__name__}: {getattr(e, '__cause__', 'unknown cause')}" + ) raise McpError( ErrorData( code=-1, - message=f"Network error importing recipe from {url}: {str(e)}", + message=f"Network error importing recipe from {url}: {error_detail}", ) ) except HTTPStatusError as e: From 8e7191e0eab4d8c78031173dc32e3050b82565ed Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:41:28 +0200 Subject: [PATCH 6/8] fix: Increase HTTP client timeout to 30s The default 5s timeout was too short for Nextcloud Cookbook app to fetch and process recipes from external URLs, causing intermittent test failures with ReadTimeout errors. Fixes intermittent CI failures in cookbook import tests. --- nextcloud_mcp_server/client/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/nextcloud_mcp_server/client/__init__.py b/nextcloud_mcp_server/client/__init__.py index 89c7adf..c363c38 100644 --- a/nextcloud_mcp_server/client/__init__.py +++ b/nextcloud_mcp_server/client/__init__.py @@ -9,6 +9,7 @@ from httpx import ( BasicAuth, Request, Response, + Timeout, ) from ..controllers.notes_search import NotesSearchController @@ -66,6 +67,9 @@ class NextcloudClient: auth=auth, transport=AsyncDisableCookieTransport(AsyncHTTPTransport()), event_hooks={"request": [log_request], "response": [log_response]}, + timeout=Timeout( + 30.0 + ), # 30 second timeout for all operations including recipe imports ) # Initialize app clients From d694243723137574039a8561107a226d9cb77289 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:46:43 +0200 Subject: [PATCH 7/8] test: Remove filter --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec11afd..526620d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,4 +62,4 @@ jobs: NEXTCLOUD_USERNAME: "admin" NEXTCLOUD_PASSWORD: "admin" run: | - uv run pytest -v --browser firefox -k 'test_mcp_cookbook_import_recipe_from_url or test_cookbook_import_recipe_from_url' + uv run pytest -v --browser firefox From b1207770ca9428761796a9ffce82f4784dc0b1ca Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Fri, 17 Oct 2025 04:47:46 +0200 Subject: [PATCH 8/8] docs: revert README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index a5af39b..db53b38 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Nextcloud MCP Server - [![Docker Image](https://img.shields.io/badge/docker-ghcr.io/cbcoutinho/nextcloud--mcp--server-blue)](https://github.com/cbcoutinho/nextcloud-mcp-server/pkgs/container/nextcloud-mcp-server) **Enable AI assistants to interact with your Nextcloud instance.**